Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f90cf0f0d | |||
| b88fe24aab | |||
| 96d42ee3e1 | |||
| 4441f58299 | |||
| 49b92e66c3 | |||
| 28ecb2e636 | |||
| 87eaaa564b | |||
| 7d9545f827 | |||
| 7f238a3168 |
15
.gitignore
vendored
15
.gitignore
vendored
@ -60,4 +60,19 @@ redis_data/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.tmp
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# Debug scripts
|
||||||
|
debug_*.py
|
||||||
|
test_*.py
|
||||||
|
|
||||||
|
# Next.js build output (including stale caches)
|
||||||
|
frontend/.next*/
|
||||||
|
frontend/next-env.d.ts
|
||||||
|
|
||||||
|
# Compiled binary
|
||||||
|
backend/ocdp-backend
|
||||||
|
|
||||||
|
# IDE / AI temp
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
|||||||
47
AGENTS.md
Normal file
47
AGENTS.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Project Overview
|
||||||
|
|
||||||
|
|
||||||
|
# 🤖 Claude Code Agentic Workflow (Strictly Follow)
|
||||||
|
|
||||||
|
作为本项目的资深 AI 研发工程师,你在执行任何指令时,必须严格遵守以下核心原则与工作流。
|
||||||
|
|
||||||
|
## Ⅰ. 核心原则 (Core Principles)
|
||||||
|
1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。
|
||||||
|
2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky,在掌握全局上下文后,用优雅的方式重构它(但不要过度设计)。
|
||||||
|
3. **Test-Driven Quality (测试驱动质量):** 在项目根目录维护 `test/` 文件夹,存放结构化测试脚本。每个脚本顶部必须用注释注明其覆盖的功能范围。当代码发生重大变更时,必须执行 `test/` 下所有相关测试脚本并确保通过,方可视为任务完成。
|
||||||
|
|
||||||
|
## Ⅱ. 任务管理闭环 (Task Management Protocol)
|
||||||
|
你必须通过读写 `tasks/` 目录下的文件来管理你的工作状态:
|
||||||
|
1. **Plan First:** 在开始实现前,将计划写入 `tasks/todo.md`,必须是可勾选的 Checkbox 列表。
|
||||||
|
2. **Verify Plan:** 在动手写代码前,先和我(User)确认这个计划是否合理。
|
||||||
|
3. **Track Progress:** 边做边在 `todo.md` 中打勾标记完成状态。
|
||||||
|
4. **Explain Changes:** 在每执行完一个步骤时,给出高层次的代码修改总结。
|
||||||
|
5. **Document Results:** 任务完成后,在 `todo.md` 中补充 Review 总结。
|
||||||
|
6. **Capture Lessons:** 如果被我纠正了错误,立刻更新 `tasks/lessons.md`。
|
||||||
|
|
||||||
|
## Ⅲ. 工作流编排 (Workflow Orchestration)
|
||||||
|
|
||||||
|
### 1. 强制规划模式 (Plan Node Default)
|
||||||
|
- 对于任何非琐碎任务(涉及 3 个以上步骤或架构决策),必须进入规划模式。
|
||||||
|
- 提前写好详细的 Spec 以减少歧义。
|
||||||
|
- **一旦情况不对劲(报错连连),立即停止盲目推进**,重新评估并制定新计划。
|
||||||
|
|
||||||
|
### 2. 经验自我迭代 (Self-Improvement Loop)
|
||||||
|
- 在每次会话开始时,主动读取 `tasks/lessons.md`,复习该项目的历史教训。
|
||||||
|
- 针对犯过的错误,为自己制定防止再次踩坑的规则。
|
||||||
|
- 无情地迭代这些经验,直到你的错误率显著下降。
|
||||||
|
|
||||||
|
### 3. 自主修复 Bug (Autonomous Bug Fixing)
|
||||||
|
- 当我给你一个 Bug 报告时:**直接去修。不要等我手把手教你。**
|
||||||
|
- 主动利用 CLI 权限去查看日志、定位错误代码、运行失败的测试用例,然后解决它。
|
||||||
|
- 要求对用户“零上下文切换”——你去修复 CI 测试,不需要我告诉你具体该怎么做。
|
||||||
|
|
||||||
|
### 4. 交付前绝对验证 (Verification Before Done)
|
||||||
|
- **永远不要在没有证明代码能跑的情况下,把任务标记为“完成”。**
|
||||||
|
- 问自己:“Staff Engineer(主任工程师)会批准这段代码吗?”
|
||||||
|
- 必须主动运行测试(例如 `go test`, `npm run build`),检查日志,并向我证明正确性。
|
||||||
|
- 对比修改前后的 Diff,确保行为符合预期。
|
||||||
|
|
||||||
|
### 5. 复杂问题拆解 (Agentic Strategy)
|
||||||
|
- 遇到极其复杂的问题时,不要试图在一个终端窗口内硬扛。
|
||||||
|
- 拆解子任务,主动进行探索性研究,针对焦点问题逐一击破。
|
||||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# Project Overview
|
||||||
|
|
||||||
|
|
||||||
|
# 🤖 Claude Code Agentic Workflow (Strictly Follow)
|
||||||
|
|
||||||
|
作为本项目的资深 AI 研发工程师,你在执行任何指令时,必须严格遵守以下核心原则与工作流。
|
||||||
|
|
||||||
|
## Ⅰ. 核心原则 (Core Principles)
|
||||||
|
1. **No Laziness (拒绝偷懒):** 必须找到问题的根本原因 (Root Causes)。禁止使用临时补丁 (Hack/Temporary fixes)。保持高级工程师的标准。
|
||||||
|
2. **Demand Elegance (苛求优雅):** 对于非琐碎的修改,停下来问自己:“有更优雅的实现方式吗?”如果你发现之前的代码很 Hacky,在掌握全局上下文后,用优雅的方式重构它(但不要过度设计)。
|
||||||
|
3. **Test-Driven Quality (测试驱动质量):** 在项目根目录维护 `test/` 文件夹,存放结构化测试脚本。每个脚本顶部必须用注释注明其覆盖的功能范围。当代码发生重大变更时,必须执行 `test/` 下所有相关测试脚本并确保通过,方可视为任务完成。
|
||||||
|
|
||||||
|
## Ⅱ. 任务管理闭环 (Task Management Protocol)
|
||||||
|
你必须通过读写 `tasks/` 目录下的文件来管理你的工作状态:
|
||||||
|
1. **Plan First:** 在开始实现前,将计划写入 `tasks/todo.md`,必须是可勾选的 Checkbox 列表。
|
||||||
|
2. **Verify Plan:** 在动手写代码前,先和我(User)确认这个计划是否合理。
|
||||||
|
3. **Track Progress:** 边做边在 `todo.md` 中打勾标记完成状态。
|
||||||
|
4. **Explain Changes:** 在每执行完一个步骤时,给出高层次的代码修改总结。
|
||||||
|
5. **Document Results:** 任务完成后,在 `todo.md` 中补充 Review 总结。
|
||||||
|
6. **Capture Lessons:** 如果被我纠正了错误,立刻更新 `tasks/lessons.md`。
|
||||||
|
|
||||||
|
## Ⅲ. 工作流编排 (Workflow Orchestration)
|
||||||
|
|
||||||
|
### 1. 强制规划模式 (Plan Node Default)
|
||||||
|
- 对于任何非琐碎任务(涉及 3 个以上步骤或架构决策),必须进入规划模式。
|
||||||
|
- 提前写好详细的 Spec 以减少歧义。
|
||||||
|
- **一旦情况不对劲(报错连连),立即停止盲目推进**,重新评估并制定新计划。
|
||||||
|
|
||||||
|
### 2. 经验自我迭代 (Self-Improvement Loop)
|
||||||
|
- 在每次会话开始时,主动读取 `tasks/lessons.md`,复习该项目的历史教训。
|
||||||
|
- 针对犯过的错误,为自己制定防止再次踩坑的规则。
|
||||||
|
- 无情地迭代这些经验,直到你的错误率显著下降。
|
||||||
|
|
||||||
|
### 3. 自主修复 Bug (Autonomous Bug Fixing)
|
||||||
|
- 当我给你一个 Bug 报告时:**直接去修。不要等我手把手教你。**
|
||||||
|
- 主动利用 CLI 权限去查看日志、定位错误代码、运行失败的测试用例,然后解决它。
|
||||||
|
- 要求对用户“零上下文切换”——你去修复 CI 测试,不需要我告诉你具体该怎么做。
|
||||||
|
|
||||||
|
### 4. 交付前绝对验证 (Verification Before Done)
|
||||||
|
- **永远不要在没有证明代码能跑的情况下,把任务标记为“完成”。**
|
||||||
|
- 问自己:“Staff Engineer(主任工程师)会批准这段代码吗?”
|
||||||
|
- 必须主动运行测试(例如 `go test`, `npm run build`),检查日志,并向我证明正确性。
|
||||||
|
- 对比修改前后的 Diff,确保行为符合预期。
|
||||||
|
|
||||||
|
### 5. 复杂问题拆解 (Agentic Strategy)
|
||||||
|
- 遇到极其复杂的问题时,不要试图在一个终端窗口内硬扛。
|
||||||
|
- 拆解子任务,主动进行探索性研究,针对焦点问题逐一击破。
|
||||||
88
Makefile
88
Makefile
@ -1,56 +1,68 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# OCDP stack orchestration Makefile
|
# OCDP root orchestration Makefile
|
||||||
# run-2: 构建前端静态资源 + 启动 nginx(统一入口)和 backend 栈
|
|
||||||
# clean-2: 清理 run-2 产生的容器 / 卷 / 网络
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
SHELL := /bin/bash
|
SHELL := /bin/bash
|
||||||
|
|
||||||
COMPOSE_BIN ?= docker compose
|
COMPOSE_BIN ?= docker compose
|
||||||
|
|
||||||
ROOT_COMPOSE := docker-compose.yml
|
ROOT_COMPOSE := docker-compose.yml
|
||||||
BACKEND_COMPOSE := backend/docker-compose.yml
|
COMPOSE := $(COMPOSE_BIN) -f $(ROOT_COMPOSE)
|
||||||
BACKEND_PROFILE := backend
|
|
||||||
|
|
||||||
COMPOSE_STACK := $(COMPOSE_BIN) -f $(ROOT_COMPOSE) -f $(BACKEND_COMPOSE) --profile $(BACKEND_PROFILE)
|
.PHONY: help install run-2 clean-2 docker-dev docker-prod docker-up docker-down docker-logs docker-ps test
|
||||||
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
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: run-2 clean-2 build-backend
|
help:
|
||||||
|
@echo ""
|
||||||
|
@echo "OCDP commands"
|
||||||
|
@echo "────────────────────────────────────────"
|
||||||
|
@echo " make install Install local Go / frontend dependencies"
|
||||||
|
@echo " make run-2 Build and start full Docker Compose stack in background"
|
||||||
|
@echo " make docker-dev Alias of run-2, kept for old docs / muscle memory"
|
||||||
|
@echo " make docker-prod Alias of run-2"
|
||||||
|
@echo " make docker-up Alias of run-2"
|
||||||
|
@echo " make docker-down Stop containers, keep volumes"
|
||||||
|
@echo " make clean-2 Stop containers and remove project volumes"
|
||||||
|
@echo " make docker-logs Follow Compose logs"
|
||||||
|
@echo " make docker-ps Show Compose service status"
|
||||||
|
@echo " make test Run structured verification script"
|
||||||
|
@echo ""
|
||||||
|
@echo "Default local ports: web=18080, https=18443, backend=18081, postgres=15432"
|
||||||
|
@echo "Override with WEB_HTTP_PORT / WEB_HTTPS_PORT / BACKEND_PORT / POSTGRES_PORT."
|
||||||
|
@echo ""
|
||||||
|
|
||||||
|
install:
|
||||||
|
@echo "→ Downloading backend modules"
|
||||||
|
@cd backend && go mod download
|
||||||
|
@echo "→ Installing frontend dependencies"
|
||||||
|
@cd frontend && npm ci
|
||||||
|
|
||||||
run-2:
|
run-2:
|
||||||
@echo "═══════════════════════════════════════════════"
|
@echo "→ Building and starting OCDP stack"
|
||||||
@echo "🚀 run-2: rebuild static assets + start web gateway stack"
|
@$(COMPOSE) up --build -d postgres backend nginx
|
||||||
@echo "═══════════════════════════════════════════════"
|
|
||||||
@echo ""
|
@echo ""
|
||||||
@export COMPOSE_PROJECT_NAME=ocdp && \
|
@$(COMPOSE) ps
|
||||||
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 ""
|
||||||
@echo "✅ Services online:"
|
@echo "Web: http://localhost:$${WEB_HTTP_PORT:-18080}"
|
||||||
@echo "═══════════════════════════════════════════════"
|
@echo "Backend: http://localhost:$${BACKEND_PORT:-18081}/health"
|
||||||
|
|
||||||
|
docker-dev: run-2
|
||||||
|
|
||||||
|
docker-prod: run-2
|
||||||
|
|
||||||
|
docker-up: run-2
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
@$(COMPOSE) down --remove-orphans
|
||||||
|
|
||||||
clean-2:
|
clean-2:
|
||||||
@echo "═══════════════════════════════════════════════"
|
@$(COMPOSE) down -v --remove-orphans
|
||||||
@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 "═══════════════════════════════════════════════"
|
|
||||||
|
|
||||||
|
docker-logs:
|
||||||
|
@$(COMPOSE) logs -f
|
||||||
|
|
||||||
|
docker-ps:
|
||||||
|
@$(COMPOSE) ps
|
||||||
|
|
||||||
|
test:
|
||||||
|
@test/readme-deployment-refresh.sh
|
||||||
|
|||||||
127
Multi-Tenant Kubeconfig.md
Normal file
127
Multi-Tenant Kubeconfig.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# Technical Specification: Multi-Tenant Kubeconfig & Auth Gateway
|
||||||
|
|
||||||
|
## 1. System Overview & Goals
|
||||||
|
- **Objective**: Develop a backend API service that automates Kubernetes multi-tenant onboarding (Namespace + Quota isolation) and securely distributes short-lived, dynamic `kubeconfig` files using the Kubernetes `TokenRequest` API.
|
||||||
|
- **Architecture Independence**: This backend service acts as a standalone control plane. It is **not** strictly bound to a BFF pattern and does **not** need to run inside the target Kubernetes cluster (it supports Out-of-Cluster execution).
|
||||||
|
- **Out of Scope**: This spec does NOT cover the frontend UI implementation or the downstream workload deployment. It focuses strictly on identity, tenant provisioning, and credential brokering.
|
||||||
|
- **Security Principles**: Adhere strictly to Zero-Knowledge architecture (no token storage in DB), Ephemeral Credentials (short-lived tokens only), and Least Privilege (the Gateway must NOT be a `cluster-admin`).
|
||||||
|
|
||||||
|
## 2. Architecture & Topology
|
||||||
|
- **Tech Stack**: Go `net/http` (or FastAPI), utilizing the official Kubernetes Client SDK (`client-go` or `kubernetes-client/python`).
|
||||||
|
- **Control Plane Flow**:
|
||||||
|
1. Client/Frontend -> Gateway: User requests environment access.
|
||||||
|
2. Gateway -> K8s API: Gateway authenticates to the target K8s cluster using its own master credentials (e.g., an Out-of-Cluster `kubeconfig`).
|
||||||
|
3. Gateway -> K8s API: Executes Namespace/SA creation (if new) or calls `TokenRequest` API (if existing).
|
||||||
|
4. Gateway -> Client/Frontend: Returns a generated `kubeconfig` YAML string with the short-lived JWT token.
|
||||||
|
|
||||||
|
## 3. Core Business Logic Workflows
|
||||||
|
|
||||||
|
### Phase 1: Tenant Initialization (Onboarding)
|
||||||
|
Triggered when a new user registers or requests a workspace for the first time. The Gateway must execute a K8s transaction creating four resources:
|
||||||
|
1. **Namespace**: `tenant-{user_uuid}`
|
||||||
|
2. **ServiceAccount**: `sa-tenant-admin` (Created inside the tenant's namespace).
|
||||||
|
3. **RoleBinding**: Bind `sa-tenant-admin` to the `admin` (or custom) ClusterRole, strictly isolated within `tenant-{user_uuid}`.
|
||||||
|
4. **ResourceQuota**: Enforce limits (e.g., `requests.cpu: "4"`, `limits.memory: "16Gi"`) to prevent noisy neighbors.
|
||||||
|
|
||||||
|
### Phase 2: Credential Distribution (Dynamic Token)
|
||||||
|
Triggered when the user requests CLI access or downloads a kubeconfig.
|
||||||
|
1. Locate the user's associated Namespace and ServiceAccount, verifying the user's ownership of the workspace.
|
||||||
|
2. Audit Logging: Record the credential issuance event (User, IP, Workspace) into the database.
|
||||||
|
3. Call the `authentication.k8s.io/v1 TokenRequest` API targeting `sa-tenant-admin` in the specific tenant's namespace.
|
||||||
|
4. Set `expirationSeconds: 7200` (2 hours). Hard limit; cannot be extended.
|
||||||
|
5. Retrieve the generated JWT token and inject it into a pre-defined `kubeconfig` text template.
|
||||||
|
|
||||||
|
### Phase 3: Automated Renewal & Emergency Suspension
|
||||||
|
- **Session Management**: If accessed via a Web UI, the Gateway intercepts requests, attaches the dynamic token, and forwards them. If the token is within 10 minutes of expiration, the Gateway automatically issues a new TokenRequest.
|
||||||
|
- **Emergency Suspension**: If a workspace is marked compromised, the Gateway deletes its K8s `RoleBinding`, instantly revoking access for all currently active tokens of that tenant.
|
||||||
|
|
||||||
|
## 4. API Contracts
|
||||||
|
|
||||||
|
### 4.1. Initialize Tenant Workspace
|
||||||
|
- **Route**: `POST /api/v1/workspaces/init`
|
||||||
|
- **Auth**: Gateway Session / Bearer Token
|
||||||
|
- **Rate Limit**: Strictly rate-limited per user to prevent Namespace exhaustion.
|
||||||
|
- **Request Payload**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tier": "basic" // Determines the ResourceQuota template
|
||||||
|
}
|
||||||
|
- **Response Payload (201 Created)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"namespace": "tenant-a1b2c3d4",
|
||||||
|
"status": "provisioned",
|
||||||
|
"quota": {"cpu": "4", "memory": "8Gi"}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
### 4.2. Generate Dynamic Kubeconfig
|
||||||
|
- **Route**: `GET /api/v1/workspaces/credentials/kubeconfig`
|
||||||
|
- **Auth**: Gateway Session / Bearer Token
|
||||||
|
- **Request Payload(200 OK)**: Returns raw `application/x-yaml`content.
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://<k8s-api-server>
|
||||||
|
certificate-authority-data: <ca-base64>
|
||||||
|
name: internal-cluster
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: internal-cluster
|
||||||
|
namespace: tenant-a1b2c3d4 # Default context locked to their namespace
|
||||||
|
user: sa-tenant-admin
|
||||||
|
name: tenant-context
|
||||||
|
current-context: tenant-context
|
||||||
|
kind: Config
|
||||||
|
users:
|
||||||
|
- name: sa-tenant-admin
|
||||||
|
user:
|
||||||
|
token: "eyJhbGciOiJSUzI1NiIs..." # Short-lived token injected here
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3. Suspend Workspace (Emergency Kill Switch)
|
||||||
|
- **Route**: POST /api/v1/workspaces/{id}/suspend
|
||||||
|
- **Auth**: Admin Only
|
||||||
|
- **Behavior**: Updates DB status to suspended and deletes the associated K8s RoleBinding.
|
||||||
|
|
||||||
|
|
||||||
|
### 5. Data Architecture & Persistence
|
||||||
|
- **Database**: PostgreSQL (Relational mapping between Users and K8s Namespaces).
|
||||||
|
- **Table**: `users`
|
||||||
|
- `id` (UUID, PK),`email`,`password_hash`,`status`
|
||||||
|
- **Table**: `workspaces`
|
||||||
|
|
||||||
|
- `id` (UUID, PK)
|
||||||
|
|
||||||
|
- `user_id` (UUID, FK to Users table)
|
||||||
|
|
||||||
|
- `k8s_namespace` (String, unique)
|
||||||
|
|
||||||
|
- `k8s_sa_name` (String)
|
||||||
|
|
||||||
|
- `tier` (String)
|
||||||
|
|
||||||
|
- `created_at` (Timestamp)
|
||||||
|
- **Table**: `audit_logs`(Security Compliance)
|
||||||
|
- `id` (UUID, PK), `user_id` (UUID), `workspace_id` (UUID), `action` (e.g., IssueKubeconfig), `ip_address`, `created_at`
|
||||||
|
- **Constraint**: We do NOT store the K8s Token in the database. Tokens are ephemeral and generated on-the-fly.
|
||||||
|
|
||||||
|
## 6. Security, Threat Mitigation & Infrastructure Constraints
|
||||||
|
|
||||||
|
### 6.1 Threat Model
|
||||||
|
| Threat | Mitigation Strategy |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Gateway Compromise** | The Gateway uses a strictly restricted K8s role. It cannot read existing `Secrets` or interfere with other tenants' running Pods. |
|
||||||
|
| **Token Theft (XSS)** | Application-level Auth must use `HttpOnly, Secure` Cookies. Generated Kubeconfigs expire in 2 hours. |
|
||||||
|
| **Resource Abuse (Mining)** | Hardcoded `ResourceQuota` per tenant upon creation. Global `LimitRange` enforced at the cluster level. |
|
||||||
|
|
||||||
|
### 6.2 Restricted Gateway Credentials (Crucial)
|
||||||
|
The Gateway requires a K8s credential (Out-of-Cluster `kubeconfig` or Cloud IAM Role) to operate. **This credential MAY NOT have `cluster-admin` privileges.** It should be bound to a custom `ClusterRole` with ONLY the following permissions:
|
||||||
|
- `create`, `get`, `list` on `namespaces`, `resourcequotas`.
|
||||||
|
- `create`, `get`, `list` on `serviceaccounts`, `rolebindings`.
|
||||||
|
- `create` on `serviceaccounts/token` (CRITICAL for TokenRequest API).
|
||||||
|
- *Strictly prohibited*: `get` or `list` on `secrets`, `pods`, or `deployments`.
|
||||||
|
|
||||||
|
### 6.3 Deployment & Networking
|
||||||
|
- **Deployment Agnostic**: The application will be packaged as a Docker image and can be deployed via Docker Compose, standalone VMs, or within a Kubernetes cluster.
|
||||||
|
- **CORS/CSP**: Since this might not be a single-origin BFF, explicit CORS policies (`Access-Control-Allow-Origin`) must be tightly defined if the frontend is hosted on a separate domain. Wildcards (`*`) are prohibited.
|
||||||
497
README.md
497
README.md
@ -1,336 +1,267 @@
|
|||||||
# OCDP - Open Cloud Development Platform
|
# OCDP - Open Cloud Deployment Platform
|
||||||
|
|
||||||
[](LICENSE)
|
OCDP 是一个面向 Kubernetes 的大模型推理部署平台。当前核心场景是:用户在页面选择 Harbor 中的 `vllm-serve` Helm Chart,填写实例名称、命名空间和 values 后,后端从 Harbor 拉取封装好的 OCI Helm Chart,并通过 Helm SDK 部署到已配置好的 Kubernetes 集群。
|
||||||
[](https://go.dev/)
|
|
||||||
[](https://nodejs.org/)
|
|
||||||
[](https://www.docker.com/)
|
|
||||||
|
|
||||||
开源云原生开发平台,用于管理 Kubernetes 集群、OCI Registry 和 Helm Charts 部署。
|
## 当前能力
|
||||||
|
|
||||||
---
|
- Registry 管理:保存 Harbor / OCI Registry 地址与凭据,敏感字段加密入库。
|
||||||
|
- Artifact 浏览:通过 Harbor v2.0 API 浏览当前凭据可见的项目、repositories 和 chart tags,避免依赖 `/v2/_catalog` 全局 catalog 权限。
|
||||||
|
- 一键部署:从前端发起实例创建,后端拉取 Chart 并在目标集群执行 Helm install/upgrade/uninstall。
|
||||||
|
- 集群管理:保存 Kubernetes API Server、CA、客户端证书或 token,用于后端连接集群。
|
||||||
|
- 实例管理:查看部署状态、Helm revision、Service/Ingress 入口信息。
|
||||||
|
- 认证:内置 JWT 登录,首次启动可通过 bootstrap 注入管理员账号。
|
||||||
|
|
||||||
## ✨ 特性
|
## 技术栈
|
||||||
|
|
||||||
- 🎯 **Registry 管理** - 支持 Harbor、Docker Registry、OCI 标准仓库
|
- 后端:Go 1.24,Gorilla Mux,Hexagonal Architecture,PostgreSQL,ORAS SDK,Helm SDK,Kubernetes client-go。
|
||||||
- 📦 **Artifact 浏览** - 浏览和管理 Helm Charts、容器镜像
|
- 前端:React 18,TypeScript,Vite,TailwindCSS。
|
||||||
- 🚀 **一键部署** - 可视化部署 Helm Charts 到 Kubernetes 集群
|
- 部署:Docker Compose,Nginx 静态文件与 `/api` 反向代理,PostgreSQL 持久化。
|
||||||
- 🔍 **智能过滤** - 按 MediaType 过滤 artifacts(chart、image、other)
|
|
||||||
- 🎨 **现代 UI** - 响应式设计,基于 React + TypeScript
|
|
||||||
- 🔐 **安全认证** - JWT 认证,加密存储敏感信息
|
|
||||||
- 🐳 **容器化** - 完整的 Docker 支持,多种运行模式
|
|
||||||
- 🔄 **热重载** - 开发模式支持代码热重载
|
|
||||||
|
|
||||||
---
|
## 项目结构
|
||||||
|
|
||||||
## 🚀 快速开始
|
```text
|
||||||
|
|
||||||
### 前置要求
|
|
||||||
|
|
||||||
- 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/
|
ocdp-go/
|
||||||
├── backend/ # Go 后端服务
|
├── backend/ # Go 后端
|
||||||
│ ├── cmd/api/ # 应用入口
|
│ ├── cmd/api/ # API 入口
|
||||||
│ ├── internal/ # 内部代码
|
│ ├── internal/adapter/input/ # HTTP REST handlers / DTO
|
||||||
│ │ ├── adapter/ # 适配器层
|
│ ├── internal/adapter/output/ # PostgreSQL / ORAS / Helm / K8s 实现
|
||||||
│ │ ├── domain/ # 领域层
|
│ ├── internal/domain/ # Entity / Repository interface / Service
|
||||||
│ │ └── bootstrap/ # 启动配置
|
│ └── internal/bootstrap/ # 首次启动数据注入
|
||||||
│ ├── Dockerfile # 生产环境
|
├── frontend/ # React + Vite 前端
|
||||||
│ ├── Dockerfile.dev # 开发环境
|
├── infra/nginx/ # Nginx 网关配置和 TLS 证书
|
||||||
│ └── Dockerfile.mock # Mock 测试
|
├── docker-compose.yml # 本地完整部署:PostgreSQL + Backend + 前端 build + Nginx
|
||||||
│
|
├── backend/docker-compose.yml # PostgreSQL + Backend + pgAdmin
|
||||||
├── frontend/ # React 前端应用
|
├── Makefile # 推荐入口:install / run-2 / docker-dev / docker-down
|
||||||
│ ├── src/
|
└── tasks/ # Agent 工作记录
|
||||||
│ │ ├── core/ # 核心功能
|
|
||||||
│ │ ├── features/ # 功能模块
|
|
||||||
│ │ └── shared/ # 共享组件
|
|
||||||
│ ├── Dockerfile # 生产环境
|
|
||||||
│ ├── Dockerfile.dev # 开发环境
|
|
||||||
│ └── Dockerfile.mock # Mock 测试
|
|
||||||
│
|
|
||||||
├── api/ # API 规范
|
|
||||||
│ └── openapi.yaml # OpenAPI 定义
|
|
||||||
│
|
|
||||||
├── docs/ # 项目文档
|
|
||||||
│ ├── features/ # 功能文档
|
|
||||||
│ ├── deployment/ # 部署文档
|
|
||||||
│ └── development/ # 开发文档
|
|
||||||
│
|
|
||||||
├── docker-compose.yml # 统一配置(使用 profiles)
|
|
||||||
└── Makefile # 便捷命令
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 常用命令
|
## 后端部署链路
|
||||||
|
|
||||||
```bash
|
1. 前端调用 `POST /api/v1/clusters/{clusterId}/instances`,提交 `name`、`namespace`、`registryId`、`repository`、`tag` 和可选 `values`。
|
||||||
# Docker 服务(推荐)
|
2. 后端 `InstanceService.CreateInstance` 校验集群、Registry 和实例名唯一性,创建 pending 记录。
|
||||||
make docker-dev # 启动开发环境
|
3. Chart 浏览使用 Harbor v2.0 API;实际部署时后端使用 ORAS SDK 访问 Harbor,将指定 repository/tag 的 Helm Chart layer 下载到 `/tmp/charts/{chart}-{version}.tgz`。
|
||||||
make docker-prod # 启动生产环境
|
4. 后端用数据库中保存的集群凭据生成临时 kubeconfig。
|
||||||
make docker-test-backend # 测试后端
|
5. Helm SDK 加载本地 chart 包,并对目标集群执行 `install`;后续通过 Helm status 同步实例状态。
|
||||||
make docker-test-frontend # 测试前端
|
6. 删除、升级和回滚实例同样通过 Helm SDK 操作目标集群。
|
||||||
make docker-logs # 查看日志
|
|
||||||
make docker-down # 停止服务
|
|
||||||
|
|
||||||
# OpenAPI 工作流
|
## 部署前准备
|
||||||
make openapi-validate # 验证 API 规范
|
|
||||||
make openapi-gen # 生成代码
|
|
||||||
make openapi-docs # 生成文档
|
|
||||||
|
|
||||||
# 本地开发(不使用 Docker)
|
需要本机已安装:
|
||||||
make install # 安装依赖
|
|
||||||
make dev-local # 启动本地开发
|
- Docker
|
||||||
make test # 运行测试
|
- Docker Compose v2 或更高版本
|
||||||
|
- Make,可选;没有 Make 时可直接执行 Compose 命令
|
||||||
|
|
||||||
|
根目录 `.env` 用于开发环境启动时注入端口、数据库、初始账号、Harbor 和 Kubernetes 集群。它是开发/测试 bootstrap 数据,不是长期配置中心;系统启动后建议在页面里维护 Registry 和 Cluster。不要提交真实 `.env`。
|
||||||
|
|
||||||
|
关键变量如下,实际值以你的 `.env` 为准:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# 登录账号 bootstrap
|
||||||
|
BOOTSTRAP_ADMIN_USER=admin
|
||||||
|
BOOTSTRAP_ADMIN_PASS=change-me
|
||||||
|
BOOTSTRAP_ADMIN_EMAIL=admin@example.com
|
||||||
|
|
||||||
|
# Harbor bootstrap
|
||||||
|
BOOTSTRAP_REGISTRY_NAME=harbor
|
||||||
|
BOOTSTRAP_REGISTRY_URL=https://harbor.example.com
|
||||||
|
BOOTSTRAP_REGISTRY_DESC=Harbor Registry
|
||||||
|
# 推荐使用 Harbor robot 账号,只授予目标项目 pull/read 权限
|
||||||
|
BOOTSTRAP_REGISTRY_ROBOT_USER='robot$project+ocdp'
|
||||||
|
BOOTSTRAP_REGISTRY_ROBOT_PASS='robot-token'
|
||||||
|
|
||||||
|
# 可选 fallback;未配置 ROBOT 变量时才会使用
|
||||||
|
BOOTSTRAP_REGISTRY_USER=admin-or-user
|
||||||
|
BOOTSTRAP_REGISTRY_PASS=change-me
|
||||||
|
BOOTSTRAP_REGISTRY_INSECURE=false
|
||||||
|
|
||||||
|
# Kubernetes 集群 bootstrap,名称列表用逗号分隔
|
||||||
|
BOOTSTRAP_CLUSTERS=cluster1,cluster2
|
||||||
|
BOOTSTRAP_CLUSTER_CLUSTER1_HOST=https://x.x.x.x:6443
|
||||||
|
BOOTSTRAP_CLUSTER_CLUSTER1_DESC=GPU Cluster 1
|
||||||
|
BOOTSTRAP_CLUSTER_CLUSTER1_CA=base64-ca-data
|
||||||
|
BOOTSTRAP_CLUSTER_CLUSTER1_CERT=base64-client-cert-data
|
||||||
|
BOOTSTRAP_CLUSTER_CLUSTER1_KEY=base64-client-key-data
|
||||||
|
|
||||||
|
# 如使用 token,可配置 TOKEN;CERT/KEY 可按实际鉴权方式留空
|
||||||
|
BOOTSTRAP_CLUSTER_CLUSTER2_HOST=https://x.x.x.x:6443
|
||||||
|
BOOTSTRAP_CLUSTER_CLUSTER2_TOKEN=token-value
|
||||||
|
|
||||||
|
# 服务端口,默认使用高位端口避免和本机其他项目冲突
|
||||||
|
WEB_HTTP_PORT=18080
|
||||||
|
WEB_HTTPS_PORT=18443
|
||||||
|
BACKEND_PORT=18081
|
||||||
|
POSTGRES_PORT=15432
|
||||||
|
|
||||||
|
# 安全与数据库
|
||||||
|
JWT_SECRET=replace-with-a-strong-secret
|
||||||
|
ENCRYPTION_KEY=replace-with-32-byte-key
|
||||||
|
POSTGRES_DB=ocdp
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=replace-me
|
||||||
|
|
||||||
|
# 可选:Docker 构建后端时使用的 Go module proxy。
|
||||||
|
# 国内网络建议保留默认值;如公司网络要求,也可改回 https://proxy.golang.org,direct。
|
||||||
|
GOPROXY=https://goproxy.cn,direct
|
||||||
|
GOSUMDB=sum.golang.google.cn
|
||||||
```
|
```
|
||||||
|
|
||||||
### 开发工作流
|
说明:
|
||||||
|
|
||||||
1. **启动开发环境**:
|
- `BOOTSTRAP_CONFIG_JSON` 优先级最高,适合把完整 bootstrap 配置作为 JSON 注入。
|
||||||
```bash
|
- 没有 `BOOTSTRAP_CONFIG_JSON` 时,后端会读取 `BOOTSTRAP_*` 变量生成初始账号、Registry 和 Cluster。
|
||||||
make docker-dev
|
- 没有任何显式 bootstrap 配置时,后端不会预注入用户、Registry 或 Cluster;代码中不再保留真实 Harbor、admin 或集群 fallback。
|
||||||
```
|
- 初始管理员必须显式配置 `BOOTSTRAP_ADMIN_USER` 和 `BOOTSTRAP_ADMIN_PASS`。如果只配置 Registry/Cluster 而未配置管理员账号,系统不会自动创建默认账号。
|
||||||
|
- Registry bootstrap 凭据优先级为 `BOOTSTRAP_REGISTRY_ROBOT_USER/PASS`,然后才是 `BOOTSTRAP_REGISTRY_USER/PASS`。Harbor robot 账号需要能访问目标项目的 repositories 和 artifacts。
|
||||||
|
- Harbor robot 用户名通常包含 `$`。本项目 Compose 已使用 raw `env_file` 传给后端;如果你在 shell 里临时 `export BOOTSTRAP_REGISTRY_ROBOT_USER=...`,请用单引号包住值,避免 shell 展开 `$project`。
|
||||||
|
- 已存在同名用户、Registry 或 Cluster 时,bootstrap 会跳过,不会覆盖数据库里的记录。
|
||||||
|
- `ENCRYPTION_KEY` 用于加密保存 Harbor 密码和集群凭据;生产环境首次启动后不要随意更换,否则旧数据无法解密。
|
||||||
|
|
||||||
2. **修改代码**(自动热重载):
|
## 推荐部署流程
|
||||||
- 后端:编辑 `backend/` 下的 Go 文件
|
|
||||||
- 前端:编辑 `frontend/src/` 下的 React 组件
|
|
||||||
|
|
||||||
3. **查看日志**:
|
当前推荐使用根目录 Makefile。`docker-dev`、`docker-prod`、`docker-up` 都是兼容旧文档的别名,实际会启动同一套完整 Docker Compose 栈:PostgreSQL、Backend、前端静态构建和 Nginx。
|
||||||
```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
|
```bash
|
||||||
# 启动后端 Mock
|
# 1. 在根目录检查 .env
|
||||||
make docker-test-backend-bg
|
ls .env
|
||||||
|
|
||||||
# 测试健康检查
|
# 2. 可选:安装本地依赖。只部署 Docker 栈时不是必须,但这个命令可用。
|
||||||
curl http://localhost:8080/health
|
make install
|
||||||
|
|
||||||
# 测试登录
|
# 3. 如果默认高位端口仍被其他项目占用,再临时换端口
|
||||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
export WEB_HTTP_PORT=18080
|
||||||
-H "Content-Type: application/json" \
|
export WEB_HTTPS_PORT=18443
|
||||||
-d '{"username":"admin","password":"admin123"}'
|
export BACKEND_PORT=18081
|
||||||
|
export POSTGRES_PORT=15432
|
||||||
|
|
||||||
# 测试 API
|
# 4. 构建并后台启动完整栈
|
||||||
curl http://localhost:8080/api/v1/registries
|
make run-2
|
||||||
curl http://localhost:8080/api/v1/clusters
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端测试
|
# 兼容旧文档,也可以执行:
|
||||||
|
make docker-dev
|
||||||
```bash
|
|
||||||
# 启动前端 Mock
|
|
||||||
make docker-test-frontend-bg
|
|
||||||
|
|
||||||
# 访问前端
|
|
||||||
open http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### 集成测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 启动完整环境
|
|
||||||
make docker-prod
|
make docker-prod
|
||||||
|
|
||||||
# 运行测试套件
|
# 5. 查看服务
|
||||||
make test
|
make docker-ps
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
访问地址:
|
||||||
|
|
||||||
## 📦 部署
|
- 前端入口:http://localhost:${WEB_HTTP_PORT:-18080}
|
||||||
|
- 后端健康检查:http://localhost:${BACKEND_PORT:-18081}/health
|
||||||
|
- Swagger UI:http://localhost:${BACKEND_PORT:-18081}/api/docs
|
||||||
|
- Nginx 健康检查:http://localhost:${WEB_HTTP_PORT:-18080}/healthz
|
||||||
|
|
||||||
### Docker Compose 部署(推荐)
|
没有 Make 时,直接用根目录 Compose 文件即可。注意要加 `--build`,因为后端镜像和前端静态资源需要构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 配置环境变量
|
docker compose up --build -d postgres backend nginx
|
||||||
export JWT_SECRET="your-production-secret"
|
|
||||||
export ENCRYPTION_KEY="your-32-byte-encryption-key"
|
|
||||||
|
|
||||||
# 2. 启动服务
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# 3. 查看状态
|
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
### Kubernetes 部署
|
如果直接执行 `docker compose up`,Compose 也会使用同一个完整栈;但在代码或 Dockerfile 改动后建议显式加 `--build`,避免复用旧镜像。
|
||||||
|
|
||||||
查看 [Kubernetes 部署指南](./docs/deployment/kubernetes-guide.md)
|
## 验证部署
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
# 健康检查
|
||||||
|
curl http://localhost:${BACKEND_PORT:-18081}/health
|
||||||
|
curl http://localhost:${WEB_HTTP_PORT:-18080}/healthz
|
||||||
|
|
||||||
## 🤝 贡献
|
# 登录,返回 token。把 password 替换成 .env 里的 BOOTSTRAP_ADMIN_PASS。
|
||||||
|
curl -s -X POST http://localhost:${BACKEND_PORT:-18081}/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"<BOOTSTRAP_ADMIN_PASS>"}'
|
||||||
|
|
||||||
欢迎贡献代码!请遵循以下步骤:
|
# 查看 bootstrap 是否生效,需要带 Bearer token
|
||||||
|
curl http://localhost:${BACKEND_PORT:-18081}/api/v1/registries \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
|
||||||
1. Fork 项目
|
curl http://localhost:${BACKEND_PORT:-18081}/api/v1/clusters \
|
||||||
2. 创建功能分支 (`git checkout -b feature/amazing-feature`)
|
-H "Authorization: Bearer <token>"
|
||||||
3. 提交更改 (`git commit -m 'feat: add amazing feature'`)
|
```
|
||||||
4. 推送分支 (`git push origin feature/amazing-feature`)
|
|
||||||
5. 创建 Pull Request
|
|
||||||
|
|
||||||
### 开发规范
|
页面验证:
|
||||||
|
|
||||||
- **代码风格**:Go (gofmt),TypeScript (ESLint + Prettier)
|
1. 打开前端入口并登录。
|
||||||
- **提交规范**:遵循 [Conventional Commits](https://www.conventionalcommits.org/)
|
2. 进入 Chart Browser,确认能看到 Harbor 中的 `vllm-serve` 或 nginx chart repository。当前默认只展示可部署 Helm chart。
|
||||||
- **测试覆盖**:新功能必须包含测试
|
3. 选择 chart tag,点击 Launch。
|
||||||
|
4. 选择目标集群、命名空间,填写实例名和 values。values 支持 schema 表单或 YAML;YAML 会在前端校验,并由后端解析为 Helm values map。
|
||||||
|
5. 提交后到实例页面查看状态;后端会异步安装并同步 Helm 状态。
|
||||||
|
|
||||||
---
|
命令行 smoke test:
|
||||||
|
|
||||||
## 📄 许可证
|
```bash
|
||||||
|
# 只验证登录、Registry health、Harbor chart 浏览和 values schema
|
||||||
|
BASE_URL=http://localhost:${BACKEND_PORT:-18081}/api/v1 \
|
||||||
|
ADMIN_USER="${BOOTSTRAP_ADMIN_USER:-admin}" \
|
||||||
|
ADMIN_PASS="<BOOTSTRAP_ADMIN_PASS>" \
|
||||||
|
./test/current-platform-smoke.sh
|
||||||
|
|
||||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情
|
# 允许真实部署时,会创建测试 release 并在结束后调用平台删除
|
||||||
|
RUN_DEPLOY_TEST=true \
|
||||||
|
TEST_NAMESPACE=ocdp-smoke \
|
||||||
|
TEST_RELEASE=ocdp-smoke-nginx \
|
||||||
|
BASE_URL=http://localhost:${BACKEND_PORT:-18081}/api/v1 \
|
||||||
|
ADMIN_PASS="<BOOTSTRAP_ADMIN_PASS>" \
|
||||||
|
./test/current-platform-smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
---
|
## 常用运维命令
|
||||||
|
|
||||||
## 🙏 致谢
|
```bash
|
||||||
|
# 查看日志
|
||||||
|
make docker-logs
|
||||||
|
|
||||||
- [Go](https://go.dev/) - 后端开发语言
|
# 重启后端
|
||||||
- [React](https://react.dev/) - 前端框架
|
docker compose restart backend
|
||||||
- [Vite](https://vitejs.dev/) - 构建工具
|
|
||||||
- [Docker](https://www.docker.com/) - 容器化平台
|
|
||||||
- [Kubernetes](https://kubernetes.io/) - 容器编排
|
|
||||||
- [Harbor](https://goharbor.io/) - OCI Registry
|
|
||||||
|
|
||||||
---
|
# 如果后端容器被重建过,Nginx 可能仍缓存旧 upstream IP;只需重启本项目 Nginx
|
||||||
|
docker compose restart nginx
|
||||||
|
|
||||||
## 📞 联系方式
|
# 停止本项目服务,但保留数据卷
|
||||||
|
make docker-down
|
||||||
|
|
||||||
- **项目主页**:https://github.com/your-org/ocdp-go
|
# 清理本项目容器和数据卷,谨慎使用
|
||||||
- **问题反馈**:https://github.com/your-org/ocdp-go/issues
|
make clean-2
|
||||||
- **文档网站**:https://docs.ocdp.example.com
|
```
|
||||||
|
|
||||||
---
|
## 本地开发与测试
|
||||||
|
|
||||||
<div align="center">
|
后端:
|
||||||
<sub>Built with ❤️ by the OCDP Team</sub>
|
|
||||||
</div>
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test ./...
|
||||||
|
go run cmd/api/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
前端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Mock 后端仍可通过 `backend/docker-compose.yml` 的 `mock` profile 启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f backend/docker-compose.yml --profile mock up -d backend-mock
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 不要为了端口冲突停止其他项目;优先通过 `WEB_HTTP_PORT`、`WEB_HTTPS_PORT`、`BACKEND_PORT`、`POSTGRES_PORT` 换端口。当前默认端口已经是 `18080/18443/18081/15432`。
|
||||||
|
- 如果旧文档提到 `make docker-dev`、`make docker-prod`,现在这些命令仍可用,都会启动同一套 Docker 栈。
|
||||||
|
- 如果之前用旧配置启动失败过,PostgreSQL 卷里可能残留旧的加密数据,表现为 `/api/v1/clusters` 或 `/api/v1/registries` 解密失败。开发/重装环境可执行 `make clean-2 && make docker-dev` 重新初始化;生产环境不要直接删卷,应先备份数据库。
|
||||||
|
- `vllm-serve` 必须以 Helm Chart OCI artifact 的形式存在于 Harbor 中;后端会寻找 Helm Chart layer 并保存为 `.tgz`。
|
||||||
|
- Harbor 浏览使用 `/api/v2.0/projects`、project repositories 和 artifacts API。若 robot 账号无法列项目或 artifacts,页面会显示明确错误;请检查 Harbor 项目成员/robot 权限,而不是给普通用户开放全局 catalog。
|
||||||
|
- values YAML 已按 YAML 解析;顶层必须是 mapping,例如 `replicaCount: 1`。
|
||||||
|
- Nginx 默认同时监听 HTTP 和 HTTPS,证书位于 `infra/nginx/certs/`,生产环境应替换为正式证书。
|
||||||
|
- `make clean-2` 会删除本项目 Compose 卷,包括 PostgreSQL 数据;只想停服务时使用 `docker compose ... down --remove-orphans`。
|
||||||
|
|
||||||
|
## API 文档
|
||||||
|
|
||||||
|
- OpenAPI YAML:[backend/docs/openapi.yaml](./backend/docs/openapi.yaml)
|
||||||
|
- 运行后 Swagger UI:`/api/docs`
|
||||||
|
|||||||
@ -4,12 +4,17 @@
|
|||||||
# ==================================================
|
# ==================================================
|
||||||
FROM golang:1.24-alpine AS builder
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
ARG GOPROXY=https://goproxy.cn,direct
|
||||||
|
ARG GOSUMDB=sum.golang.google.cn
|
||||||
|
ENV GOPROXY=${GOPROXY}
|
||||||
|
ENV GOSUMDB=${GOSUMDB}
|
||||||
|
|
||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN sh -c 'for i in 1 2 3; do go mod download && exit 0; echo "go mod download failed, retrying ($i/3)" >&2; sleep 5; done; go mod download'
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o ocdp-backend cmd/api/main.go
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o ocdp-backend cmd/api/main.go
|
||||||
|
|||||||
@ -27,14 +27,17 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/rest"
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/rest"
|
||||||
"github.com/ocdp/cluster-service/internal/adapter/output"
|
"github.com/ocdp/cluster-service/internal/adapter/output"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/output/k8s"
|
||||||
"github.com/ocdp/cluster-service/internal/bootstrap"
|
"github.com/ocdp/cluster-service/internal/bootstrap"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
"github.com/ocdp/cluster-service/internal/pkg/jwt"
|
"github.com/ocdp/cluster-service/internal/pkg/jwt"
|
||||||
"github.com/ocdp/cluster-service/internal/pkg/password"
|
"github.com/ocdp/cluster-service/internal/pkg/password"
|
||||||
@ -72,6 +75,7 @@ func main() {
|
|||||||
// ===== 5. 创建 Domain Services =====
|
// ===== 5. 创建 Domain Services =====
|
||||||
authService := service.NewAuthService(
|
authService := service.NewAuthService(
|
||||||
repos.UserRepo,
|
repos.UserRepo,
|
||||||
|
repos.WorkspaceRepo,
|
||||||
passwordHasher,
|
passwordHasher,
|
||||||
tokenGenerator,
|
tokenGenerator,
|
||||||
)
|
)
|
||||||
@ -97,20 +101,32 @@ func main() {
|
|||||||
repos.HelmClient,
|
repos.HelmClient,
|
||||||
repos.OCIClient,
|
repos.OCIClient,
|
||||||
repos.EntryClient,
|
repos.EntryClient,
|
||||||
|
repos.BindingRepo,
|
||||||
)
|
)
|
||||||
|
instanceService.SetDiagnosticsClient(repos.DiagnosticsClient)
|
||||||
|
instanceService.SetTenantProvisioning(repos.WorkspaceRepo, repos.TenantKubeClient)
|
||||||
|
instanceService.SetScaleClient(k8s.NewScaleClient())
|
||||||
|
|
||||||
monitoringService := service.NewMonitoringService(
|
monitoringService := service.NewMonitoringService(
|
||||||
repos.ClusterRepo,
|
repos.ClusterRepo,
|
||||||
repos.MetricsClient,
|
repos.MetricsClient,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
workspaceService := service.NewWorkspaceService(
|
||||||
|
repos.WorkspaceRepo,
|
||||||
|
repos.BindingRepo,
|
||||||
|
repos.ClusterRepo,
|
||||||
|
repos.TenantKubeClient,
|
||||||
|
repos.AuditRepo,
|
||||||
|
)
|
||||||
|
|
||||||
log.Println("✅ Domain Services initialized")
|
log.Println("✅ Domain Services initialized")
|
||||||
|
|
||||||
// ===== 6. 加载并执行 Bootstrap 预注入 =====
|
// ===== 6. 加载并执行 Bootstrap 预注入 =====
|
||||||
bootstrapConfig, err := bootstrap.LoadBootstrapConfig()
|
bootstrapConfig, err := bootstrap.LoadBootstrapConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ Warning: Failed to load bootstrap config: %v", err)
|
log.Printf("⚠️ Warning: Failed to load bootstrap config: %v", err)
|
||||||
// 使用默认配置
|
// 使用安全的空配置,避免在配置错误时写入任何预置账号或集群凭据。
|
||||||
bootstrapConfig = bootstrap.GetDefaultBootstrapConfig()
|
bootstrapConfig = bootstrap.GetDefaultBootstrapConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +142,7 @@ func main() {
|
|||||||
artifactHandler := rest.NewArtifactHandler(artifactService)
|
artifactHandler := rest.NewArtifactHandler(artifactService)
|
||||||
instanceHandler := rest.NewInstanceHandler(instanceService)
|
instanceHandler := rest.NewInstanceHandler(instanceService)
|
||||||
monitoringHandler := rest.NewMonitoringHandler(monitoringService)
|
monitoringHandler := rest.NewMonitoringHandler(monitoringService)
|
||||||
|
workspaceHandler := rest.NewWorkspaceHandler(workspaceService)
|
||||||
swaggerHandler := rest.NewSwaggerHandler()
|
swaggerHandler := rest.NewSwaggerHandler()
|
||||||
|
|
||||||
log.Println("✅ Input Adapters (REST handlers) initialized")
|
log.Println("✅ Input Adapters (REST handlers) initialized")
|
||||||
@ -133,11 +150,13 @@ func main() {
|
|||||||
// ===== 8. 设置路由 =====
|
// ===== 8. 设置路由 =====
|
||||||
router := setupRouter(
|
router := setupRouter(
|
||||||
authHandler,
|
authHandler,
|
||||||
|
authService,
|
||||||
clusterHandler,
|
clusterHandler,
|
||||||
registryHandler,
|
registryHandler,
|
||||||
artifactHandler,
|
artifactHandler,
|
||||||
instanceHandler,
|
instanceHandler,
|
||||||
monitoringHandler,
|
monitoringHandler,
|
||||||
|
workspaceHandler,
|
||||||
swaggerHandler,
|
swaggerHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -191,11 +210,13 @@ func getEnv(key, defaultValue string) string {
|
|||||||
// setupRouter 设置路由
|
// setupRouter 设置路由
|
||||||
func setupRouter(
|
func setupRouter(
|
||||||
authHandler *rest.AuthHandler,
|
authHandler *rest.AuthHandler,
|
||||||
|
authService *service.AuthService,
|
||||||
clusterHandler *rest.ClusterHandler,
|
clusterHandler *rest.ClusterHandler,
|
||||||
registryHandler *rest.RegistryHandler,
|
registryHandler *rest.RegistryHandler,
|
||||||
artifactHandler *rest.ArtifactHandler,
|
artifactHandler *rest.ArtifactHandler,
|
||||||
instanceHandler *rest.InstanceHandler,
|
instanceHandler *rest.InstanceHandler,
|
||||||
monitoringHandler *rest.MonitoringHandler,
|
monitoringHandler *rest.MonitoringHandler,
|
||||||
|
workspaceHandler *rest.WorkspaceHandler,
|
||||||
swaggerHandler *rest.SwaggerHandler,
|
swaggerHandler *rest.SwaggerHandler,
|
||||||
) *mux.Router {
|
) *mux.Router {
|
||||||
router := mux.NewRouter().StrictSlash(true)
|
router := mux.NewRouter().StrictSlash(true)
|
||||||
@ -222,45 +243,66 @@ func setupRouter(
|
|||||||
api := router.PathPrefix("/api/v1").Subrouter()
|
api := router.PathPrefix("/api/v1").Subrouter()
|
||||||
|
|
||||||
// ===== 认证路由 =====
|
// ===== 认证路由 =====
|
||||||
api.HandleFunc("/auth/register", authHandler.Register)
|
|
||||||
api.HandleFunc("/auth/login", authHandler.Login)
|
api.HandleFunc("/auth/login", authHandler.Login)
|
||||||
api.HandleFunc("/auth/refresh", authHandler.RefreshToken)
|
api.HandleFunc("/auth/refresh", authHandler.RefreshToken)
|
||||||
|
|
||||||
|
protected := api.PathPrefix("").Subrouter()
|
||||||
|
protected.Use(authMiddleware(authService))
|
||||||
|
protected.HandleFunc("/auth/me", authHandler.Me).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/auth/register", authHandler.Register).Methods(http.MethodPost)
|
||||||
|
protected.HandleFunc("/users", authHandler.ListUsers).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/users", authHandler.Register).Methods(http.MethodPost)
|
||||||
|
protected.HandleFunc("/users/{user_id}", authHandler.UpdateUser).Methods(http.MethodPut)
|
||||||
|
protected.HandleFunc("/users/{user_id}", authHandler.DeleteUser).Methods(http.MethodDelete)
|
||||||
|
|
||||||
// ===== 集群路由 =====
|
// ===== 集群路由 =====
|
||||||
api.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost)
|
protected.HandleFunc("/clusters", clusterHandler.CreateCluster).Methods(http.MethodPost)
|
||||||
api.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet)
|
protected.HandleFunc("/clusters", clusterHandler.GetAllClusters).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.GetCluster).Methods(http.MethodGet)
|
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.GetCluster).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.UpdateCluster).Methods(http.MethodPut)
|
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.UpdateCluster).Methods(http.MethodPut)
|
||||||
api.HandleFunc("/clusters/{cluster_id}", clusterHandler.DeleteCluster).Methods(http.MethodDelete)
|
protected.HandleFunc("/clusters/{cluster_id}", clusterHandler.DeleteCluster).Methods(http.MethodDelete)
|
||||||
api.HandleFunc("/clusters/{cluster_id}/health", clusterHandler.GetClusterHealth).Methods(http.MethodGet)
|
protected.HandleFunc("/clusters/{cluster_id}/health", clusterHandler.GetClusterHealth).Methods(http.MethodGet)
|
||||||
|
|
||||||
// ===== Registry 路由 =====
|
// ===== Registry 路由 =====
|
||||||
api.HandleFunc("/registries", registryHandler.CreateRegistry).Methods(http.MethodPost)
|
protected.HandleFunc("/registries", registryHandler.CreateRegistry).Methods(http.MethodPost)
|
||||||
api.HandleFunc("/registries", registryHandler.GetAllRegistries).Methods(http.MethodGet)
|
protected.HandleFunc("/registries", registryHandler.GetAllRegistries).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/registries/{registry_id}", registryHandler.GetRegistry).Methods(http.MethodGet)
|
protected.HandleFunc("/registries/{registry_id}", registryHandler.GetRegistry).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/registries/{registry_id}", registryHandler.UpdateRegistry).Methods(http.MethodPut)
|
protected.HandleFunc("/registries/{registry_id}", registryHandler.UpdateRegistry).Methods(http.MethodPut)
|
||||||
api.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete)
|
protected.HandleFunc("/registries/{registry_id}", registryHandler.DeleteRegistry).Methods(http.MethodDelete)
|
||||||
api.HandleFunc("/registries/{registry_id}/health", registryHandler.GetRegistryHealth).Methods(http.MethodGet)
|
protected.HandleFunc("/registries/{registry_id}/health", registryHandler.GetRegistryHealth).Methods(http.MethodGet)
|
||||||
|
|
||||||
// ===== Artifact 路由 =====
|
// ===== Artifact 路由 =====
|
||||||
api.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet)
|
protected.HandleFunc("/registries/{registry_id}/repositories", artifactHandler.ListRepositories).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts", artifactHandler.ListArtifacts).Methods(http.MethodGet)
|
protected.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)
|
protected.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)
|
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-schema", artifactHandler.GetArtifactValuesSchema).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/registries/{registry_id}/repositories/{repository_name:.+}/artifacts/{reference}/values-yaml", artifactHandler.GetArtifactValuesYAML).Methods(http.MethodGet)
|
||||||
|
|
||||||
// ===== Instance 路由 =====
|
// ===== Instance 路由 =====
|
||||||
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost)
|
protected.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.CreateInstance).Methods(http.MethodPost)
|
||||||
api.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.ListInstances).Methods(http.MethodGet)
|
protected.HandleFunc("/clusters/{cluster_id}/instances", instanceHandler.ListInstances).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.GetInstance).Methods(http.MethodGet)
|
protected.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)
|
protected.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)
|
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}", instanceHandler.DeleteInstance).Methods(http.MethodDelete)
|
||||||
api.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/entries", instanceHandler.ListInstanceEntries).Methods(http.MethodGet)
|
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/entries", instanceHandler.ListInstanceEntries).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/diagnostics", instanceHandler.GetInstanceDiagnostics).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/logs/stream", instanceHandler.StreamInstanceLogs).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/scale", instanceHandler.ScaleInstance).Methods(http.MethodPost)
|
||||||
|
protected.HandleFunc("/clusters/{cluster_id}/instances/{instance_id}/values-diff", instanceHandler.GetInstanceValuesDiff).Methods(http.MethodGet)
|
||||||
|
|
||||||
// ===== Monitoring 路由 =====
|
// ===== Monitoring 路由 =====
|
||||||
api.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)
|
protected.HandleFunc("/monitoring/clusters", monitoringHandler.ListClusterMonitoring).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/monitoring/clusters/{cluster_id}", monitoringHandler.GetClusterMonitoring).Methods(http.MethodGet)
|
protected.HandleFunc("/monitoring/clusters/{cluster_id}", monitoringHandler.GetClusterMonitoring).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/monitoring/clusters/{cluster_id}/nodes", monitoringHandler.GetNodeMetrics).Methods(http.MethodGet)
|
protected.HandleFunc("/monitoring/clusters/{cluster_id}/nodes", monitoringHandler.GetNodeMetrics).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/monitoring/summary", monitoringHandler.GetMonitoringSummary).Methods(http.MethodGet)
|
protected.HandleFunc("/monitoring/summary", monitoringHandler.GetMonitoringSummary).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
// ===== Workspace 路由 =====
|
||||||
|
protected.HandleFunc("/workspaces", workspaceHandler.ListWorkspaces).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/workspaces", workspaceHandler.CreateWorkspace).Methods(http.MethodPost)
|
||||||
|
protected.HandleFunc("/workspaces/credentials/kubeconfig", workspaceHandler.IssueCurrentKubeconfig).Methods(http.MethodGet)
|
||||||
|
protected.HandleFunc("/workspaces/{workspace_id}/clusters", workspaceHandler.InitClusterBinding).Methods(http.MethodPost)
|
||||||
|
protected.HandleFunc("/workspaces/{workspace_id}/kubeconfig", workspaceHandler.IssueKubeconfig).Methods(http.MethodPost)
|
||||||
|
protected.HandleFunc("/workspaces/{workspace_id}/suspend", workspaceHandler.SuspendWorkspace).Methods(http.MethodPost)
|
||||||
|
|
||||||
// 处理 MethodNotAllowed 错误(OPTIONS 请求会触发)
|
// 处理 MethodNotAllowed 错误(OPTIONS 请求会触发)
|
||||||
router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
router.MethodNotAllowedHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -275,6 +317,35 @@ func setupRouter(
|
|||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func authMiddleware(authService *service.AuthService) mux.MiddlewareFunc {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "Unauthorized", "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||||
|
if token == "" {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "Unauthorized", "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, err := authService.VerifyAccessToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r.WithContext(authz.WithPrincipal(r.Context(), principal)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSONError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"error":%q,"message":%q}`, code, message)))
|
||||||
|
}
|
||||||
|
|
||||||
// loggingMiddleware 日志中间件
|
// loggingMiddleware 日志中间件
|
||||||
func loggingMiddleware(next http.Handler) http.Handler {
|
func loggingMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"username": "admin",
|
"username": "bootstrap-admin",
|
||||||
"password": "change-me-in-production",
|
"password": "replace-with-a-strong-password",
|
||||||
"email": "admin@example.com"
|
"email": "bootstrap-admin@example.local"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"registries": [
|
"registries": [
|
||||||
@ -12,8 +12,8 @@
|
|||||||
"name": "my-harbor",
|
"name": "my-harbor",
|
||||||
"url": "https://harbor.example.com",
|
"url": "https://harbor.example.com",
|
||||||
"description": "Harbor Registry",
|
"description": "Harbor Registry",
|
||||||
"username": "admin",
|
"username": "robot$project+ocdp",
|
||||||
"password": "change-me",
|
"password": "replace-with-robot-token",
|
||||||
"insecure": false
|
"insecure": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -28,4 +28,3 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,7 +37,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
||||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
|
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"
|
||||||
ports:
|
ports:
|
||||||
- "${POSTGRES_PORT:-5432}:5432"
|
- "${POSTGRES_PORT:-15432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ${INIT_DB_SQL_PATH:-./scripts/init-db.sql}:/docker-entrypoint-initdb.d/01-init.sql:ro
|
- ${INIT_DB_SQL_PATH:-./scripts/init-db.sql}:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
@ -58,9 +58,16 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ${BACKEND_BUILD_CONTEXT:-.}
|
context: ${BACKEND_BUILD_CONTEXT:-.}
|
||||||
dockerfile: ${BACKEND_BUILD_DOCKERFILE:-Dockerfile}
|
dockerfile: ${BACKEND_BUILD_DOCKERFILE:-Dockerfile}
|
||||||
|
args:
|
||||||
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
|
GOSUMDB: ${GOSUMDB:-sum.golang.google.cn}
|
||||||
image: ocdp-backend:latest
|
image: ocdp-backend:latest
|
||||||
container_name: ocdp-backend
|
container_name: ocdp-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
|
format: raw
|
||||||
environment:
|
environment:
|
||||||
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
@ -68,12 +75,12 @@ services:
|
|||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8080}:8080"
|
- "${BACKEND_PORT:-18081}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config:ro
|
- ./config:/app/config:ro
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@ -94,6 +101,9 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ${BACKEND_BUILD_CONTEXT:-.}
|
context: ${BACKEND_BUILD_CONTEXT:-.}
|
||||||
dockerfile: ${BACKEND_MOCK_BUILD_DOCKERFILE:-Dockerfile.mock}
|
dockerfile: ${BACKEND_MOCK_BUILD_DOCKERFILE:-Dockerfile.mock}
|
||||||
|
args:
|
||||||
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
|
GOSUMDB: ${GOSUMDB:-sum.golang.google.cn}
|
||||||
container_name: ocdp-backend-mock
|
container_name: ocdp-backend-mock
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@ -102,9 +112,9 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-test-jwt-secret-key}
|
JWT_SECRET: ${JWT_SECRET:-test-jwt-secret-key}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-test-encryption-key-32-bytes-long}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-test-encryption-key-32-bytes-long}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-8080}:8080"
|
- "${BACKEND_PORT:-18081}:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8080/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@ -124,7 +134,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@ocdp.local}
|
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@ocdp.local}
|
||||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin}
|
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-change-me}
|
||||||
PGADMIN_CONFIG_SERVER_MODE: "False"
|
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||||
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
|
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: "False"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -6,9 +6,9 @@ type RepositoryListResponse struct {
|
|||||||
RegistryURL string `json:"registryUrl"`
|
RegistryURL string `json:"registryUrl"`
|
||||||
Repositories []string `json:"repositories"`
|
Repositories []string `json:"repositories"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported
|
CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported
|
||||||
Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable"
|
Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable"
|
||||||
Message string `json:"message,omitempty"` // User-friendly message
|
Message string `json:"message,omitempty"` // User-friendly message
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactResponse Artifact 响应(简化版本,只包含核心字段)
|
// ArtifactResponse Artifact 响应(简化版本,只包含核心字段)
|
||||||
@ -23,11 +23,11 @@ type ArtifactResponse struct {
|
|||||||
|
|
||||||
// TagResponse Tag 响应(前端期望的扁平化结构)
|
// TagResponse Tag 响应(前端期望的扁平化结构)
|
||||||
type TagResponse struct {
|
type TagResponse struct {
|
||||||
RepositoryName string `json:"repositoryName"` // Repository name
|
RepositoryName string `json:"repositoryName"` // Repository name
|
||||||
Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest")
|
Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest")
|
||||||
Type string `json:"type"` // Artifact type: chart, image, other
|
Type string `json:"type"` // Artifact type: chart, image, other
|
||||||
MediaType string `json:"mediaType,omitempty"`
|
MediaType string `json:"mediaType,omitempty"`
|
||||||
Size int64 `json:"size"` // Artifact size (bytes)
|
Size int64 `json:"size"` // Artifact size (bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口)
|
// ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口)
|
||||||
@ -42,3 +42,7 @@ type ValuesSchemaResponse struct {
|
|||||||
Schema string `json:"schema"`
|
Schema string `json:"schema"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValuesYAMLResponse Helm Chart 默认 values.yaml 响应
|
||||||
|
type ValuesYAMLResponse struct {
|
||||||
|
ValuesYAML string `json:"valuesYaml"`
|
||||||
|
}
|
||||||
|
|||||||
@ -2,8 +2,18 @@ package dto
|
|||||||
|
|
||||||
// RegisterRequest 用户注册请求
|
// RegisterRequest 用户注册请求
|
||||||
type RegisterRequest struct {
|
type RegisterRequest struct {
|
||||||
Username string `json:"username" binding:"required"`
|
Username string `json:"username" binding:"required"`
|
||||||
Password string `json:"password" binding:"required,min=6"`
|
Password string `json:"password" binding:"required,min=6"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
WorkspaceID string `json:"workspaceId,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||||
|
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||||
|
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||||
|
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||||
|
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||||
|
IsActive *bool `json:"isActive,omitempty"`
|
||||||
|
MustChangePassword *bool `json:"mustChangePassword,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginRequest 用户登录请求
|
// LoginRequest 用户登录请求
|
||||||
@ -19,17 +29,53 @@ type RefreshTokenRequest struct {
|
|||||||
|
|
||||||
// AuthResponse 认证响应
|
// AuthResponse 认证响应
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
AccessToken string `json:"accessToken"`
|
AccessToken string `json:"accessToken"`
|
||||||
RefreshToken string `json:"refreshToken"`
|
RefreshToken string `json:"refreshToken"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
WorkspaceID string `json:"workspaceId"`
|
||||||
|
WorkspaceName string `json:"workspaceName,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||||
|
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||||
|
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||||
|
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||||
|
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||||
|
Permissions []string `json:"permissions,omitempty"`
|
||||||
|
PermissionVersion int `json:"permissionVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserResponse 用户信息响应
|
// UserResponse 用户信息响应
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
CreatedAt string `json:"createdAt"`
|
Role string `json:"role"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
WorkspaceID string `json:"workspaceId"`
|
||||||
|
WorkspaceName string `json:"workspaceName,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||||
|
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||||
|
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||||
|
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||||
|
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||||
|
IsActive bool `json:"isActive"`
|
||||||
|
MustChangePassword bool `json:"mustChangePassword"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserRequest 管理员更新用户状态/角色请求
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
WorkspaceID string `json:"workspaceId,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||||
|
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||||
|
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||||
|
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||||
|
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||||
|
IsActive *bool `json:"isActive,omitempty"`
|
||||||
|
MustChangePassword *bool `json:"mustChangePassword,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,30 +2,38 @@ package dto
|
|||||||
|
|
||||||
// CreateClusterRequest 创建集群请求
|
// CreateClusterRequest 创建集群请求
|
||||||
type CreateClusterRequest struct {
|
type CreateClusterRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Host string `json:"host" binding:"required"`
|
Host string `json:"host" binding:"required"`
|
||||||
CAData string `json:"caData"`
|
CAData string `json:"caData"`
|
||||||
CADataAlt string `json:"ca_data"`
|
CADataAlt string `json:"ca_data"`
|
||||||
CertData string `json:"certData"`
|
CertData string `json:"certData"`
|
||||||
CertDataAlt string `json:"cert_data"`
|
CertDataAlt string `json:"cert_data"`
|
||||||
KeyData string `json:"keyData"`
|
KeyData string `json:"keyData"`
|
||||||
KeyDataAlt string `json:"key_data"`
|
KeyDataAlt string `json:"key_data"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
GlobalShared bool `json:"globalShared"`
|
||||||
|
GlobalSharedAlt bool `json:"global_shared"`
|
||||||
|
DefaultNamespace string `json:"defaultNamespace"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateClusterRequest 更新集群请求
|
// UpdateClusterRequest 更新集群请求
|
||||||
type UpdateClusterRequest struct {
|
type UpdateClusterRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
CAData string `json:"caData"`
|
CAData string `json:"caData"`
|
||||||
CADataAlt string `json:"ca_data"`
|
CADataAlt string `json:"ca_data"`
|
||||||
CertData string `json:"certData"`
|
CertData string `json:"certData"`
|
||||||
CertDataAlt string `json:"cert_data"`
|
CertDataAlt string `json:"cert_data"`
|
||||||
KeyData string `json:"keyData"`
|
KeyData string `json:"keyData"`
|
||||||
KeyDataAlt string `json:"key_data"`
|
KeyDataAlt string `json:"key_data"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
GlobalShared bool `json:"globalShared"`
|
||||||
|
GlobalSharedAlt bool `json:"global_shared"`
|
||||||
|
DefaultNamespace string `json:"defaultNamespace"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize 将多种命名风格的字段合并到统一字段
|
// Normalize 将多种命名风格的字段合并到统一字段
|
||||||
@ -56,10 +64,15 @@ func (r *UpdateClusterRequest) Normalize() {
|
|||||||
|
|
||||||
// ClusterResponse 集群响应(敏感数据已脱敏)
|
// ClusterResponse 集群响应(敏感数据已脱敏)
|
||||||
type ClusterResponse struct {
|
type ClusterResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
WorkspaceID string `json:"workspaceId"`
|
||||||
|
OwnerID string `json:"ownerId"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
DefaultNamespace string `json:"defaultNamespace,omitempty"`
|
||||||
|
AllowedActions []string `json:"allowedActions,omitempty"`
|
||||||
// 认证配置状态(不返回实际证书数据,仅返回是否已配置)
|
// 认证配置状态(不返回实际证书数据,仅返回是否已配置)
|
||||||
HasCAData bool `json:"hasCaData"`
|
HasCAData bool `json:"hasCaData"`
|
||||||
HasCertData bool `json:"hasCertData"`
|
HasCertData bool `json:"hasCertData"`
|
||||||
|
|||||||
@ -9,6 +9,9 @@ import (
|
|||||||
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
|
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
|
||||||
response := &RegistryResponse{
|
response := &RegistryResponse{
|
||||||
ID: registry.ID,
|
ID: registry.ID,
|
||||||
|
WorkspaceID: registry.WorkspaceID,
|
||||||
|
OwnerID: registry.OwnerID,
|
||||||
|
Visibility: registry.Visibility,
|
||||||
Name: registry.Name,
|
Name: registry.Name,
|
||||||
URL: registry.URL,
|
URL: registry.URL,
|
||||||
Description: registry.Description,
|
Description: registry.Description,
|
||||||
@ -17,33 +20,37 @@ func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
|
|||||||
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 脱敏处理密码
|
// 脱敏处理密码
|
||||||
if registry.Password != "" {
|
if registry.Password != "" {
|
||||||
response.HasPassword = true
|
response.HasPassword = true
|
||||||
response.Password = crypto.MaskSensitiveData(registry.Password)
|
response.Password = crypto.MaskSensitiveData(registry.Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToClusterResponse 转换 Cluster 实体为响应 DTO(脱敏)
|
// ToClusterResponse 转换 Cluster 实体为响应 DTO(脱敏)
|
||||||
func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
|
func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
|
||||||
response := &ClusterResponse{
|
response := &ClusterResponse{
|
||||||
ID: cluster.ID,
|
ID: cluster.ID,
|
||||||
Name: cluster.Name,
|
WorkspaceID: cluster.WorkspaceID,
|
||||||
Host: cluster.Host,
|
OwnerID: cluster.OwnerID,
|
||||||
Description: cluster.Description,
|
Visibility: cluster.Visibility,
|
||||||
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
Name: cluster.Name,
|
||||||
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
Host: cluster.Host,
|
||||||
|
Description: cluster.Description,
|
||||||
|
DefaultNamespace: cluster.DefaultNamespace,
|
||||||
|
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置认证配置状态标志
|
// 设置认证配置状态标志
|
||||||
response.HasCAData = cluster.CAData != ""
|
response.HasCAData = cluster.CAData != ""
|
||||||
response.HasCertData = cluster.CertData != ""
|
response.HasCertData = cluster.CertData != ""
|
||||||
response.HasKeyData = cluster.KeyData != ""
|
response.HasKeyData = cluster.KeyData != ""
|
||||||
response.HasToken = cluster.Token != ""
|
response.HasToken = cluster.Token != ""
|
||||||
|
|
||||||
// 脱敏处理敏感数据(仅显示掩码)
|
// 脱敏处理敏感数据(仅显示掩码)
|
||||||
if cluster.CAData != "" {
|
if cluster.CAData != "" {
|
||||||
response.CAData = crypto.MaskSensitiveData(cluster.CAData)
|
response.CAData = crypto.MaskSensitiveData(cluster.CAData)
|
||||||
@ -57,7 +64,6 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
|
|||||||
if cluster.Token != "" {
|
if cluster.Token != "" {
|
||||||
response.Token = crypto.MaskSensitiveData(cluster.Token)
|
response.Token = crypto.MaskSensitiveData(cluster.Token)
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,4 +12,3 @@ type SuccessResponse struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data interface{} `json:"data,omitempty"`
|
Data interface{} `json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,23 +2,25 @@ package dto
|
|||||||
|
|
||||||
// CreateInstanceRequest 创建实例请求
|
// CreateInstanceRequest 创建实例请求
|
||||||
type CreateInstanceRequest struct {
|
type CreateInstanceRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Namespace string `json:"namespace" binding:"required"`
|
Namespace string `json:"namespace" binding:"required"`
|
||||||
RegistryID string `json:"registryId" binding:"required"`
|
RegistryID string `json:"registryId" binding:"required"`
|
||||||
RegistryIDAlt string `json:"registry_id"`
|
RegistryIDAlt string `json:"registry_id"`
|
||||||
Repository string `json:"repository" binding:"required"`
|
Repository string `json:"repository" binding:"required"`
|
||||||
Tag string `json:"tag" binding:"required"`
|
Tag string `json:"tag" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Values map[string]interface{} `json:"values"`
|
Values map[string]interface{} `json:"values"`
|
||||||
ValuesYAML string `json:"valuesYaml"`
|
ValuesYAML string `json:"valuesYaml"`
|
||||||
|
ValuesYAMLAlt string `json:"values_yaml"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateInstanceRequest 更新实例请求
|
// UpdateInstanceRequest 更新实例请求
|
||||||
type UpdateInstanceRequest struct {
|
type UpdateInstanceRequest struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Values map[string]interface{} `json:"values"`
|
Values map[string]interface{} `json:"values"`
|
||||||
ValuesYAML string `json:"valuesYaml"`
|
ValuesYAML string `json:"valuesYaml"`
|
||||||
|
ValuesYAMLAlt string `json:"values_yaml"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize 将多种命名风格的字段合并到统一字段
|
// Normalize 将多种命名风格的字段合并到统一字段
|
||||||
@ -26,6 +28,16 @@ func (r *CreateInstanceRequest) Normalize() {
|
|||||||
if r.RegistryID == "" {
|
if r.RegistryID == "" {
|
||||||
r.RegistryID = r.RegistryIDAlt
|
r.RegistryID = r.RegistryIDAlt
|
||||||
}
|
}
|
||||||
|
if r.ValuesYAML == "" {
|
||||||
|
r.ValuesYAML = r.ValuesYAMLAlt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize 将多种命名风格的字段合并到统一字段
|
||||||
|
func (r *UpdateInstanceRequest) Normalize() {
|
||||||
|
if r.ValuesYAML == "" {
|
||||||
|
r.ValuesYAML = r.ValuesYAMLAlt
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RollbackInstanceRequest 回滚实例请求
|
// RollbackInstanceRequest 回滚实例请求
|
||||||
@ -43,23 +55,27 @@ type DeleteInstanceRequest struct {
|
|||||||
|
|
||||||
// InstanceResponse 实例响应
|
// InstanceResponse 实例响应
|
||||||
type InstanceResponse struct {
|
type InstanceResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ClusterID string `json:"clusterId"`
|
ClusterID string `json:"clusterId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Namespace string `json:"namespace"`
|
Namespace string `json:"namespace"`
|
||||||
RegistryID string `json:"registryId"`
|
RegistryID string `json:"registryId"`
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
Chart string `json:"chart"`
|
Chart string `json:"chart"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusReason string `json:"statusReason,omitempty"`
|
WorkspaceID string `json:"workspaceId"`
|
||||||
LastOperation string `json:"lastOperation,omitempty"`
|
OwnerID string `json:"ownerId"`
|
||||||
LastError string `json:"lastError,omitempty"`
|
AllowedActions []string `json:"allowedActions,omitempty"`
|
||||||
Revision int `json:"revision"`
|
StatusReason string `json:"statusReason,omitempty"`
|
||||||
Values map[string]interface{} `json:"values,omitempty"`
|
LastOperation string `json:"lastOperation,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
LastError string `json:"lastError,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
Revision int `json:"revision"`
|
||||||
|
Values map[string]interface{} `json:"values,omitempty"`
|
||||||
|
Replicas int `json:"replicas"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstanceStatusResponse 实例状态响应
|
// InstanceStatusResponse 实例状态响应
|
||||||
@ -131,3 +147,89 @@ type InstanceEntryResponse struct {
|
|||||||
Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"`
|
Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"`
|
||||||
TLS []InstanceEntryTLSResponse `json:"tls,omitempty"`
|
TLS []InstanceEntryTLSResponse `json:"tls,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InstanceDiagnosticsResponse struct {
|
||||||
|
InstanceName string `json:"instanceName"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Pods []InstancePodDiagnostics `json:"pods"`
|
||||||
|
Services []InstanceServiceDiagnostics `json:"services"`
|
||||||
|
Events []InstanceEventDiagnostics `json:"events"`
|
||||||
|
Logs []InstancePodLogResponse `json:"logs"`
|
||||||
|
CollectedAt string `json:"collectedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstancePodDiagnostics struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
NodeName string `json:"nodeName,omitempty"`
|
||||||
|
PodIP string `json:"podIp,omitempty"`
|
||||||
|
HostIP string `json:"hostIp,omitempty"`
|
||||||
|
RestartCount int32 `json:"restartCount"`
|
||||||
|
Containers []InstanceContainerDiagnostics `json:"containers"`
|
||||||
|
Conditions []InstanceConditionDiagnostics `json:"conditions"`
|
||||||
|
CreationTimestamp string `json:"creationTimestamp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceContainerDiagnostics struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
RestartCount int32 `json:"restartCount"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceConditionDiagnostics struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceServiceDiagnostics struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ClusterIP string `json:"clusterIP,omitempty"`
|
||||||
|
Ports []InstanceEntryPortResponse `json:"ports,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceEventDiagnostics struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
InvolvedKind string `json:"involvedKind"`
|
||||||
|
InvolvedName string `json:"involvedName"`
|
||||||
|
Count int32 `json:"count"`
|
||||||
|
FirstTimestamp string `json:"firstTimestamp,omitempty"`
|
||||||
|
LastTimestamp string `json:"lastTimestamp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleInstanceRequest 扩缩容实例请求
|
||||||
|
type ScaleInstanceRequest struct {
|
||||||
|
Replicas int `json:"replicas" binding:"required"`
|
||||||
|
Workload string `json:"workload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleInstanceResponse 扩缩容实例响应
|
||||||
|
type ScaleInstanceResponse struct {
|
||||||
|
Instance *InstanceResponse `json:"instance"`
|
||||||
|
Replicas int `json:"replicas"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceValuesDiffResponse 实例 values 差异响应
|
||||||
|
type InstanceValuesDiffResponse struct {
|
||||||
|
Current map[string]interface{} `json:"current"`
|
||||||
|
Defaults map[string]interface{} `json:"defaults"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstancePodLogResponse struct {
|
||||||
|
Pod string `json:"pod"`
|
||||||
|
Container string `json:"container"`
|
||||||
|
TailLines int64 `json:"tailLines"`
|
||||||
|
Log string `json:"log,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@ -8,29 +8,29 @@ import (
|
|||||||
|
|
||||||
// ClusterMetricsResponse 集群监控响应
|
// ClusterMetricsResponse 集群监控响应
|
||||||
type ClusterMetricsResponse struct {
|
type ClusterMetricsResponse struct {
|
||||||
ClusterID string `json:"clusterId"`
|
ClusterID string `json:"clusterId"`
|
||||||
ClusterName string `json:"clusterName"`
|
ClusterName string `json:"clusterName"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
NodeCount int `json:"nodeCount"`
|
NodeCount int `json:"nodeCount"`
|
||||||
PodCount int `json:"podCount"`
|
PodCount int `json:"podCount"`
|
||||||
LastCheck time.Time `json:"lastCheck"`
|
LastCheck time.Time `json:"lastCheck"`
|
||||||
TotalCPU string `json:"totalCpu"`
|
TotalCPU string `json:"totalCpu"`
|
||||||
TotalMemory string `json:"totalMemory"`
|
TotalMemory string `json:"totalMemory"`
|
||||||
TotalGPU int `json:"totalGpu"`
|
TotalGPU int `json:"totalGpu"`
|
||||||
UsedCPU string `json:"usedCpu"`
|
UsedCPU string `json:"usedCpu"`
|
||||||
UsedMemory string `json:"usedMemory"`
|
UsedMemory string `json:"usedMemory"`
|
||||||
UsedGPU int `json:"usedGpu"`
|
UsedGPU int `json:"usedGpu"`
|
||||||
CPUUsage float64 `json:"cpuUsage"`
|
CPUUsage float64 `json:"cpuUsage"`
|
||||||
MemoryUsage float64 `json:"memoryUsage"`
|
MemoryUsage float64 `json:"memoryUsage"`
|
||||||
GPUUsage float64 `json:"gpuUsage"`
|
GPUUsage float64 `json:"gpuUsage"`
|
||||||
MaxNodeCPU string `json:"maxNodeCpu"`
|
MaxNodeCPU string `json:"maxNodeCpu"`
|
||||||
MaxNodeMemory string `json:"maxNodeMemory"`
|
MaxNodeMemory string `json:"maxNodeMemory"`
|
||||||
MaxNodeGPU int `json:"maxNodeGpu"`
|
MaxNodeGPU int `json:"maxNodeGpu"`
|
||||||
MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"`
|
MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"`
|
||||||
MaxNodeMemUsage float64 `json:"maxNodeMemUsage"`
|
MaxNodeMemUsage float64 `json:"maxNodeMemUsage"`
|
||||||
MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"`
|
MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"`
|
||||||
Nodes []NodeMetricsResponse `json:"nodes,omitempty"`
|
Nodes []NodeMetricsResponse `json:"nodes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeMetricsResponse 节点监控响应
|
// NodeMetricsResponse 节点监控响应
|
||||||
@ -72,28 +72,28 @@ type MonitoringSummaryResponse struct {
|
|||||||
// ToClusterMetricsResponse 转换为响应
|
// ToClusterMetricsResponse 转换为响应
|
||||||
func ToClusterMetricsResponse(m *entity.ClusterMetrics) *ClusterMetricsResponse {
|
func ToClusterMetricsResponse(m *entity.ClusterMetrics) *ClusterMetricsResponse {
|
||||||
resp := &ClusterMetricsResponse{
|
resp := &ClusterMetricsResponse{
|
||||||
ClusterID: m.ClusterID,
|
ClusterID: m.ClusterID,
|
||||||
ClusterName: m.ClusterName,
|
ClusterName: m.ClusterName,
|
||||||
Status: m.Status,
|
Status: m.Status,
|
||||||
Uptime: m.Uptime,
|
Uptime: m.Uptime,
|
||||||
NodeCount: m.NodeCount,
|
NodeCount: m.NodeCount,
|
||||||
PodCount: m.PodCount,
|
PodCount: m.PodCount,
|
||||||
LastCheck: m.LastCheck,
|
LastCheck: m.LastCheck,
|
||||||
TotalCPU: m.TotalCPU,
|
TotalCPU: m.TotalCPU,
|
||||||
TotalMemory: m.TotalMemory,
|
TotalMemory: m.TotalMemory,
|
||||||
TotalGPU: m.TotalGPU,
|
TotalGPU: m.TotalGPU,
|
||||||
UsedCPU: m.UsedCPU,
|
UsedCPU: m.UsedCPU,
|
||||||
UsedMemory: m.UsedMemory,
|
UsedMemory: m.UsedMemory,
|
||||||
UsedGPU: m.UsedGPU,
|
UsedGPU: m.UsedGPU,
|
||||||
CPUUsage: m.CPUUsage,
|
CPUUsage: m.CPUUsage,
|
||||||
MemoryUsage: m.MemoryUsage,
|
MemoryUsage: m.MemoryUsage,
|
||||||
GPUUsage: m.GPUUsage,
|
GPUUsage: m.GPUUsage,
|
||||||
MaxNodeCPU: m.MaxNodeCPU,
|
MaxNodeCPU: m.MaxNodeCPU,
|
||||||
MaxNodeMemory: m.MaxNodeMemory,
|
MaxNodeMemory: m.MaxNodeMemory,
|
||||||
MaxNodeGPU: m.MaxNodeGPU,
|
MaxNodeGPU: m.MaxNodeGPU,
|
||||||
MaxNodeCPUUsage: m.MaxNodeCPUUsage,
|
MaxNodeCPUUsage: m.MaxNodeCPUUsage,
|
||||||
MaxNodeMemUsage: m.MaxNodeMemUsage,
|
MaxNodeMemUsage: m.MaxNodeMemUsage,
|
||||||
MaxNodeGPUUsage: m.MaxNodeGPUUsage,
|
MaxNodeGPUUsage: m.MaxNodeGPUUsage,
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(m.Nodes) > 0 {
|
if len(m.Nodes) > 0 {
|
||||||
@ -140,4 +140,3 @@ func ToMonitoringSummaryResponse(s *entity.MonitoringSummary) *MonitoringSummary
|
|||||||
LastUpdate: s.LastUpdate,
|
LastUpdate: s.LastUpdate,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,36 +2,46 @@ package dto
|
|||||||
|
|
||||||
// CreateRegistryRequest 创建 Registry 请求
|
// CreateRegistryRequest 创建 Registry 请求
|
||||||
type CreateRegistryRequest struct {
|
type CreateRegistryRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
URL string `json:"url" binding:"required"`
|
URL string `json:"url" binding:"required"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Insecure bool `json:"insecure"`
|
Insecure bool `json:"insecure"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
GlobalShared bool `json:"globalShared"`
|
||||||
|
GlobalSharedAlt bool `json:"global_shared"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRegistryRequest 更新 Registry 请求
|
// UpdateRegistryRequest 更新 Registry 请求
|
||||||
type UpdateRegistryRequest struct {
|
type UpdateRegistryRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Insecure bool `json:"insecure"`
|
Insecure bool `json:"insecure"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
GlobalShared bool `json:"globalShared"`
|
||||||
|
GlobalSharedAlt bool `json:"global_shared"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistryResponse Registry 响应(敏感数据已脱敏)
|
// RegistryResponse Registry 响应(敏感数据已脱敏)
|
||||||
type RegistryResponse struct {
|
type RegistryResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
|
WorkspaceID string `json:"workspaceId"`
|
||||||
Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
|
OwnerID string `json:"ownerId"`
|
||||||
HasPassword bool `json:"hasPassword"` // 是否已设置密码
|
Visibility string `json:"visibility"`
|
||||||
Insecure bool `json:"insecure"`
|
AllowedActions []string `json:"allowedActions,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
|
||||||
UpdatedAt string `json:"updatedAt"`
|
Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
|
||||||
|
HasPassword bool `json:"hasPassword"` // 是否已设置密码
|
||||||
|
Insecure bool `json:"insecure"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistryHealthResponse Registry 健康状态响应
|
// RegistryHealthResponse Registry 健康状态响应
|
||||||
@ -39,4 +49,3 @@ type RegistryHealthResponse struct {
|
|||||||
Healthy bool `json:"healthy"`
|
Healthy bool `json:"healthy"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -29,14 +29,19 @@ func NewArtifactHandler(artifactService *service.ArtifactService) *ArtifactHandl
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param registry_id path string true "Registry ID"
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Param artifact_type query string false "Artifact type filter (chart, all)" default(chart)
|
||||||
// @Success 200 {object} dto.RepositoryListResponse
|
// @Success 200 {object} dto.RepositoryListResponse
|
||||||
// @Failure 500 {object} dto.ErrorResponse
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
// @Router /registries/{registry_id}/repositories [get]
|
// @Router /registries/{registry_id}/repositories [get]
|
||||||
func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Request) {
|
func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
registryID := vars["registry_id"]
|
registryID := vars["registry_id"]
|
||||||
|
artifactType := r.URL.Query().Get("artifact_type")
|
||||||
|
if artifactType == "" {
|
||||||
|
artifactType = "chart"
|
||||||
|
}
|
||||||
|
|
||||||
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID)
|
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID, artifactType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error())
|
respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error())
|
||||||
return
|
return
|
||||||
@ -50,13 +55,17 @@ func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine source and message based on repository count
|
// Determine source and message based on repository count
|
||||||
source := "catalog"
|
source := "harbor-api"
|
||||||
catalogSupported := true
|
catalogSupported := true
|
||||||
message := ""
|
message := ""
|
||||||
|
|
||||||
if len(repositories) == 0 {
|
if len(repositories) == 0 {
|
||||||
source = "unavailable"
|
source = "unavailable"
|
||||||
message = "No repositories found in this registry"
|
if artifactType == "chart" {
|
||||||
|
message = "No chart repositories found in this registry"
|
||||||
|
} else {
|
||||||
|
message = "No repositories found in this registry"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &dto.RepositoryListResponse{
|
response := &dto.RepositoryListResponse{
|
||||||
@ -191,3 +200,37 @@ func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http
|
|||||||
|
|
||||||
respondJSON(w, http.StatusOK, response)
|
respondJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetArtifactValuesYAML 获取 Helm Chart 的默认 values.yaml
|
||||||
|
// @Summary 获取 Helm Chart 默认 Values YAML
|
||||||
|
// @Description 获取 Helm Chart 包内原始 values.yaml,用于高级覆盖编辑
|
||||||
|
// @Tags Artifacts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param registry_id path string true "Registry ID"
|
||||||
|
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||||
|
// @Param reference path string true "Artifact Reference (tag or digest)"
|
||||||
|
// @Success 200 {object} dto.ValuesYAMLResponse
|
||||||
|
// @Failure 500 {object} dto.ErrorResponse
|
||||||
|
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values-yaml [get]
|
||||||
|
func (h *ArtifactHandler) GetArtifactValuesYAML(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
registryID := vars["registry_id"]
|
||||||
|
repositoryName := vars["repository_name"]
|
||||||
|
reference := vars["reference"]
|
||||||
|
|
||||||
|
valuesYAML, err := h.artifactService.GetValuesYAML(r.Context(), registryID, repositoryName, reference)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, entity.ErrRegistryNotFound),
|
||||||
|
errors.Is(err, entity.ErrRepositoryNotFound),
|
||||||
|
errors.Is(err, entity.ErrArtifactNotFound):
|
||||||
|
respondError(w, http.StatusNotFound, "Values YAML not found", err.Error())
|
||||||
|
default:
|
||||||
|
respondError(w, http.StatusInternalServerError, "Failed to get values YAML", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, &dto.ValuesYAMLResponse{ValuesYAML: valuesYAML})
|
||||||
|
}
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
package rest
|
package rest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler 认证 Handler
|
// AuthHandler 认证 Handler
|
||||||
@ -20,9 +25,9 @@ func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 用户注册
|
// Register 管理员创建用户
|
||||||
// @Summary 用户注册
|
// @Summary 管理员创建用户
|
||||||
// @Description 创建一个新的后台用户
|
// @Description 创建一个新的后台用户。公开自注册已禁用,只允许 admin 调用。
|
||||||
// @Tags Auth
|
// @Tags Auth
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@ -38,22 +43,64 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用领域服务
|
// 调用领域服务
|
||||||
user, err := h.authService.Register(r.Context(), req.Username, req.Password)
|
user, err := h.authService.Register(r.Context(), req.Username, req.Password, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
|
||||||
|
Namespace: req.Namespace,
|
||||||
|
DefaultClusterID: req.DefaultClusterID,
|
||||||
|
QuotaCPU: req.QuotaCPU,
|
||||||
|
QuotaMemory: req.QuotaMemory,
|
||||||
|
QuotaGPU: req.QuotaGPU,
|
||||||
|
QuotaGPUMem: req.QuotaGPUMem,
|
||||||
|
}, req.IsActive, req.MustChangePassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusBadRequest, "Registration failed", err.Error())
|
respondServiceError(w, err, "Registration failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回响应
|
respondJSON(w, http.StatusCreated, h.convertUserResponse(r.Context(), user))
|
||||||
response := &dto.UserResponse{
|
}
|
||||||
ID: user.ID,
|
|
||||||
Username: user.Username,
|
|
||||||
Email: user.Email,
|
|
||||||
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, response)
|
func (h *AuthHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
users, err := h.authService.ListUsers(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
responses := make([]*dto.UserResponse, 0, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
responses = append(responses, h.convertUserResponse(r.Context(), user))
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, responses)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := mux.Vars(r)["user_id"]
|
||||||
|
var req dto.UpdateUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.authService.UpdateUser(r.Context(), userID, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
|
||||||
|
Namespace: req.Namespace,
|
||||||
|
DefaultClusterID: req.DefaultClusterID,
|
||||||
|
QuotaCPU: req.QuotaCPU,
|
||||||
|
QuotaMemory: req.QuotaMemory,
|
||||||
|
QuotaGPU: req.QuotaGPU,
|
||||||
|
QuotaGPUMem: req.QuotaGPUMem,
|
||||||
|
}, req.IsActive, req.MustChangePassword)
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to update user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, h.convertUserResponse(r.Context(), user))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := mux.Vars(r)["user_id"]
|
||||||
|
if err := h.authService.DeleteUser(r.Context(), userID); err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to delete user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 用户登录
|
// Login 用户登录
|
||||||
@ -74,25 +121,58 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用领域服务
|
// 调用领域服务
|
||||||
accessToken, refreshToken, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
accessToken, refreshToken, user, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
|
respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户信息
|
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
|
||||||
// TODO: 从 token 解析用户信息或从服务获取
|
|
||||||
|
|
||||||
// 返回响应
|
// 返回响应
|
||||||
response := &dto.AuthResponse{
|
response := &dto.AuthResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
Username: req.Username,
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
WorkspaceID: user.WorkspaceID,
|
||||||
|
WorkspaceName: workspaceName(workspace),
|
||||||
|
Namespace: workspaceNamespace(workspace),
|
||||||
|
DefaultClusterID: workspaceDefaultClusterID(workspace),
|
||||||
|
QuotaCPU: workspaceQuotaCPU(workspace),
|
||||||
|
QuotaMemory: workspaceQuotaMemory(workspace),
|
||||||
|
QuotaGPU: workspaceQuotaGPU(workspace),
|
||||||
|
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
|
||||||
|
Permissions: authz.PermissionsForRole(user.Role),
|
||||||
|
PermissionVersion: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, response)
|
respondJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) convertUserResponse(ctx context.Context, user *entity.User) *dto.UserResponse {
|
||||||
|
workspace, _ := h.authService.GetWorkspaceByID(ctx, user.WorkspaceID)
|
||||||
|
return &dto.UserResponse{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Email: user.Email,
|
||||||
|
Role: user.Role,
|
||||||
|
WorkspaceID: user.WorkspaceID,
|
||||||
|
WorkspaceName: workspaceName(workspace),
|
||||||
|
Namespace: workspaceNamespace(workspace),
|
||||||
|
DefaultClusterID: workspaceDefaultClusterID(workspace),
|
||||||
|
QuotaCPU: workspaceQuotaCPU(workspace),
|
||||||
|
QuotaMemory: workspaceQuotaMemory(workspace),
|
||||||
|
QuotaGPU: workspaceQuotaGPU(workspace),
|
||||||
|
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
|
||||||
|
IsActive: user.IsActive,
|
||||||
|
MustChangePassword: user.MustChangePassword,
|
||||||
|
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新 Token
|
// RefreshToken 刷新 Token
|
||||||
// @Summary 刷新访问令牌
|
// @Summary 刷新访问令牌
|
||||||
// @Description 使用刷新令牌获取新的访问令牌
|
// @Description 使用刷新令牌获取新的访问令牌
|
||||||
@ -111,17 +191,109 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 调用领域服务
|
// 调用领域服务
|
||||||
newAccessToken, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
|
newAccessToken, user, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error())
|
respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
|
||||||
|
|
||||||
// 返回响应
|
// 返回响应
|
||||||
response := &dto.AuthResponse{
|
response := &dto.AuthResponse{
|
||||||
AccessToken: newAccessToken,
|
AccessToken: newAccessToken,
|
||||||
RefreshToken: req.RefreshToken,
|
RefreshToken: req.RefreshToken,
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
WorkspaceID: user.WorkspaceID,
|
||||||
|
WorkspaceName: workspaceName(workspace),
|
||||||
|
Namespace: workspaceNamespace(workspace),
|
||||||
|
DefaultClusterID: workspaceDefaultClusterID(workspace),
|
||||||
|
QuotaCPU: workspaceQuotaCPU(workspace),
|
||||||
|
QuotaMemory: workspaceQuotaMemory(workspace),
|
||||||
|
QuotaGPU: workspaceQuotaGPU(workspace),
|
||||||
|
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
|
||||||
|
Permissions: authz.PermissionsForRole(user.Role),
|
||||||
|
PermissionVersion: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, response)
|
respondJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
|
||||||
|
if token == "" || token == header {
|
||||||
|
respondError(w, http.StatusUnauthorized, "Unauthorized", "missing bearer token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, err := h.authService.VerifyAccessToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, &dto.AuthResponse{
|
||||||
|
UserID: principal.UserID,
|
||||||
|
Username: principal.Username,
|
||||||
|
Role: principal.Role,
|
||||||
|
WorkspaceID: principal.WorkspaceID,
|
||||||
|
WorkspaceName: principal.WorkspaceName,
|
||||||
|
Namespace: principal.Namespace,
|
||||||
|
DefaultClusterID: principal.DefaultClusterID,
|
||||||
|
QuotaCPU: principal.QuotaCPU,
|
||||||
|
QuotaMemory: principal.QuotaMemory,
|
||||||
|
QuotaGPU: principal.QuotaGPU,
|
||||||
|
QuotaGPUMem: principal.QuotaGPUMem,
|
||||||
|
Permissions: principal.Permissions,
|
||||||
|
PermissionVersion: principal.PermissionVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceName(workspace *entity.Workspace) string {
|
||||||
|
if workspace == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return workspace.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceNamespace(workspace *entity.Workspace) string {
|
||||||
|
if workspace == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return workspace.K8sNamespace
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceDefaultClusterID(workspace *entity.Workspace) string {
|
||||||
|
if workspace == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return workspace.DefaultClusterID
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceQuotaCPU(workspace *entity.Workspace) string {
|
||||||
|
if workspace == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return workspace.QuotaCPU
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceQuotaMemory(workspace *entity.Workspace) string {
|
||||||
|
if workspace == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return workspace.QuotaMemory
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceQuotaGPU(workspace *entity.Workspace) string {
|
||||||
|
if workspace == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return workspace.QuotaGPU
|
||||||
|
}
|
||||||
|
|
||||||
|
func workspaceQuotaGPUMem(workspace *entity.Workspace) string {
|
||||||
|
if workspace == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return workspace.QuotaGPUMem
|
||||||
|
}
|
||||||
|
|||||||
@ -45,6 +45,11 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 创建实体
|
// 创建实体
|
||||||
cluster := entity.NewCluster(req.Name, req.Host)
|
cluster := entity.NewCluster(req.Name, req.Host)
|
||||||
cluster.Description = req.Description
|
cluster.Description = req.Description
|
||||||
|
cluster.Visibility = req.Visibility
|
||||||
|
if req.GlobalShared || req.GlobalSharedAlt {
|
||||||
|
cluster.Visibility = "global_shared"
|
||||||
|
}
|
||||||
|
cluster.DefaultNamespace = req.DefaultNamespace
|
||||||
|
|
||||||
if req.CertData != "" && req.KeyData != "" {
|
if req.CertData != "" && req.KeyData != "" {
|
||||||
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||||
@ -147,6 +152,15 @@ func (h *ClusterHandler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// 更新字段
|
// 更新字段
|
||||||
cluster.Update(req.Name, req.Host, req.Description)
|
cluster.Update(req.Name, req.Host, req.Description)
|
||||||
|
if req.Visibility != "" {
|
||||||
|
cluster.Visibility = req.Visibility
|
||||||
|
}
|
||||||
|
if req.GlobalShared || req.GlobalSharedAlt {
|
||||||
|
cluster.Visibility = "global_shared"
|
||||||
|
}
|
||||||
|
if req.DefaultNamespace != "" {
|
||||||
|
cluster.DefaultNamespace = req.DefaultNamespace
|
||||||
|
}
|
||||||
|
|
||||||
if req.CertData != "" && req.KeyData != "" {
|
if req.CertData != "" && req.KeyData != "" {
|
||||||
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||||
|
|||||||
@ -2,13 +2,17 @@ package rest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstanceHandler 实例 Handler
|
// InstanceHandler 实例 Handler
|
||||||
@ -69,6 +73,14 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
if req.ValuesYAML != "" {
|
if req.ValuesYAML != "" {
|
||||||
instance.SetValuesYAML(req.ValuesYAML)
|
instance.SetValuesYAML(req.ValuesYAML)
|
||||||
|
if req.Values == nil {
|
||||||
|
values, err := parseValuesYAML(req.ValuesYAML)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid values YAML", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance.SetValues(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用领域服务
|
// 调用领域服务
|
||||||
@ -77,28 +89,7 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回响应
|
respondJSON(w, http.StatusCreated, convertInstanceResponse(instance, true))
|
||||||
response := &dto.InstanceResponse{
|
|
||||||
ID: instance.ID,
|
|
||||||
ClusterID: instance.ClusterID,
|
|
||||||
Name: instance.Name,
|
|
||||||
Namespace: instance.Namespace,
|
|
||||||
RegistryID: instance.RegistryID,
|
|
||||||
Repository: instance.Repository,
|
|
||||||
Chart: instance.Chart,
|
|
||||||
Version: instance.Version,
|
|
||||||
Description: instance.Description,
|
|
||||||
Status: string(instance.Status),
|
|
||||||
StatusReason: instance.StatusReason,
|
|
||||||
LastOperation: string(instance.LastOperation),
|
|
||||||
LastError: instance.LastError,
|
|
||||||
Revision: instance.Revision,
|
|
||||||
Values: instance.Values,
|
|
||||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstance 获取实例详情
|
// GetInstance 获取实例详情
|
||||||
@ -113,6 +104,7 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
|||||||
// @Router /clusters/{cluster_id}/instances/{instance_id} [get]
|
// @Router /clusters/{cluster_id}/instances/{instance_id} [get]
|
||||||
func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
instanceID := vars["instance_id"]
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||||
@ -120,28 +112,12 @@ func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "Instance not found", err.Error())
|
respondError(w, http.StatusNotFound, "Instance not found", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if instance.ClusterID != clusterID {
|
||||||
response := &dto.InstanceResponse{
|
respondError(w, http.StatusNotFound, "Instance not found", "resource does not belong to cluster")
|
||||||
ID: instance.ID,
|
return
|
||||||
ClusterID: instance.ClusterID,
|
|
||||||
Name: instance.Name,
|
|
||||||
Namespace: instance.Namespace,
|
|
||||||
RegistryID: instance.RegistryID,
|
|
||||||
Repository: instance.Repository,
|
|
||||||
Chart: instance.Chart,
|
|
||||||
Version: instance.Version,
|
|
||||||
Description: instance.Description,
|
|
||||||
Status: string(instance.Status),
|
|
||||||
StatusReason: instance.StatusReason,
|
|
||||||
LastOperation: string(instance.LastOperation),
|
|
||||||
LastError: instance.LastError,
|
|
||||||
Revision: instance.Revision,
|
|
||||||
Values: instance.Values,
|
|
||||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, response)
|
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInstances 列出集群的所有实例
|
// ListInstances 列出集群的所有实例
|
||||||
@ -159,30 +135,16 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID)
|
instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to list instances", err.Error())
|
respondServiceError(w, err, "Failed to list instances")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich with running replicas from K8s
|
||||||
|
instances = h.instanceService.EnrichReplicas(r.Context(), clusterID, instances)
|
||||||
|
|
||||||
responses := make([]*dto.InstanceResponse, 0, len(instances))
|
responses := make([]*dto.InstanceResponse, 0, len(instances))
|
||||||
for _, instance := range instances {
|
for _, instance := range instances {
|
||||||
responses = append(responses, &dto.InstanceResponse{
|
responses = append(responses, convertInstanceResponse(instance, false))
|
||||||
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{
|
response := &dto.InstanceListResponse{
|
||||||
@ -214,6 +176,7 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
|||||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.Normalize()
|
||||||
|
|
||||||
// 获取现有实例
|
// 获取现有实例
|
||||||
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||||
@ -225,12 +188,22 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
|||||||
// 更新字段
|
// 更新字段
|
||||||
if req.Version != "" {
|
if req.Version != "" {
|
||||||
instance.Upgrade(req.Version, req.Values)
|
instance.Upgrade(req.Version, req.Values)
|
||||||
|
} else if req.Values != nil {
|
||||||
|
instance.SetValues(req.Values)
|
||||||
}
|
}
|
||||||
if req.Description != "" {
|
if req.Description != "" {
|
||||||
instance.Description = req.Description
|
instance.Description = req.Description
|
||||||
}
|
}
|
||||||
if req.ValuesYAML != "" {
|
if req.ValuesYAML != "" {
|
||||||
instance.SetValuesYAML(req.ValuesYAML)
|
instance.SetValuesYAML(req.ValuesYAML)
|
||||||
|
if req.Values == nil {
|
||||||
|
values, err := parseValuesYAML(req.ValuesYAML)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid values YAML", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance.SetValues(values)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用领域服务
|
// 调用领域服务
|
||||||
@ -239,27 +212,7 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := &dto.InstanceResponse{
|
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true))
|
||||||
ID: instance.ID,
|
|
||||||
ClusterID: instance.ClusterID,
|
|
||||||
Name: instance.Name,
|
|
||||||
Namespace: instance.Namespace,
|
|
||||||
RegistryID: instance.RegistryID,
|
|
||||||
Repository: instance.Repository,
|
|
||||||
Chart: instance.Chart,
|
|
||||||
Version: instance.Version,
|
|
||||||
Description: instance.Description,
|
|
||||||
Status: string(instance.Status),
|
|
||||||
StatusReason: instance.StatusReason,
|
|
||||||
LastOperation: string(instance.LastOperation),
|
|
||||||
LastError: instance.LastError,
|
|
||||||
Revision: instance.Revision,
|
|
||||||
Values: instance.Values,
|
|
||||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteInstance 删除实例
|
// DeleteInstance 删除实例
|
||||||
@ -320,6 +273,153 @@ func (h *InstanceHandler) ListInstanceEntries(w http.ResponseWriter, r *http.Req
|
|||||||
respondJSON(w, http.StatusOK, responses)
|
respondJSON(w, http.StatusOK, responses)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *InstanceHandler) GetInstanceDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
tailLines := int64(200)
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("tailLines")); raw != "" {
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid tailLines", "tailLines must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tailLines = parsed
|
||||||
|
} else if raw := strings.TrimSpace(r.URL.Query().Get("tail_lines")); raw != "" {
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid tail_lines", "tail_lines must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tailLines = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics, err := h.instanceService.GetInstanceDiagnostics(r.Context(), clusterID, instanceID, tailLines)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch err {
|
||||||
|
case entity.ErrInstanceNotFound, entity.ErrClusterNotFound:
|
||||||
|
status = http.StatusNotFound
|
||||||
|
case entity.ErrForbidden:
|
||||||
|
status = http.StatusForbidden
|
||||||
|
}
|
||||||
|
respondError(w, status, "Failed to collect instance diagnostics", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, convertInstanceDiagnostics(diagnostics))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *InstanceHandler) StreamInstanceLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
|
podName := strings.TrimSpace(r.URL.Query().Get("pod"))
|
||||||
|
containerName := strings.TrimSpace(r.URL.Query().Get("container"))
|
||||||
|
if podName == "" || containerName == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "Missing required query parameter", "both 'pod' and 'container' are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tailLines := int64(200)
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("tailLines")); raw != "" {
|
||||||
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid tailLines", "tailLines must be a positive integer")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tailLines = parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, errs, err := h.instanceService.StreamInstanceLogs(r.Context(), clusterID, instanceID, podName, containerName, tailLines)
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusInternalServerError
|
||||||
|
switch err {
|
||||||
|
case entity.ErrInstanceNotFound, entity.ErrClusterNotFound:
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
respondError(w, status, "Failed to stream instance logs", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
respondError(w, http.StatusInternalServerError, "Streaming not supported", "server does not support response flushing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case line, open := <-lines:
|
||||||
|
if !open {
|
||||||
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", line)
|
||||||
|
flusher.Flush()
|
||||||
|
case err, open := <-errs:
|
||||||
|
if open && err != nil {
|
||||||
|
fmt.Fprintf(w, "data: [ERROR] %s\n\n", err.Error())
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleInstance 扩缩容实例
|
||||||
|
func (h *InstanceHandler) ScaleInstance(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
|
var req dto.ScaleInstanceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Replicas < 0 {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid replicas", "replicas must be >= 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.instanceService.ScaleInstance(r.Context(), clusterID, instanceID, req.Replicas, req.Workload)
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to scale instance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
instResp := convertInstanceResponse(result, true)
|
||||||
|
instResp.Replicas = req.Replicas
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, dto.ScaleInstanceResponse{
|
||||||
|
Instance: instResp,
|
||||||
|
Replicas: req.Replicas,
|
||||||
|
Message: fmt.Sprintf("Scaled to %d replicas", req.Replicas),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstanceValuesDiff 获取实例 values 差异
|
||||||
|
func (h *InstanceHandler) GetInstanceValuesDiff(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
clusterID := vars["cluster_id"]
|
||||||
|
instanceID := vars["instance_id"]
|
||||||
|
|
||||||
|
diff, err := h.instanceService.GetInstanceValuesDiff(r.Context(), clusterID, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to get values diff")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, diff)
|
||||||
|
}
|
||||||
|
|
||||||
func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
|
func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
|
||||||
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
|
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
|
||||||
for _, port := range entry.Ports {
|
for _, port := range entry.Ports {
|
||||||
@ -369,3 +469,196 @@ func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryRespons
|
|||||||
TLS: tlsResponses,
|
TLS: tlsResponses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertInstanceDiagnostics(diagnostics *entity.InstanceDiagnostics) *dto.InstanceDiagnosticsResponse {
|
||||||
|
if diagnostics == nil {
|
||||||
|
return &dto.InstanceDiagnosticsResponse{}
|
||||||
|
}
|
||||||
|
pods := make([]dto.InstancePodDiagnostics, 0, len(diagnostics.Pods))
|
||||||
|
for _, pod := range diagnostics.Pods {
|
||||||
|
containers := make([]dto.InstanceContainerDiagnostics, 0, len(pod.Containers))
|
||||||
|
for _, container := range pod.Containers {
|
||||||
|
containers = append(containers, dto.InstanceContainerDiagnostics{
|
||||||
|
Name: container.Name,
|
||||||
|
Image: container.Image,
|
||||||
|
Ready: container.Ready,
|
||||||
|
RestartCount: container.RestartCount,
|
||||||
|
State: container.State,
|
||||||
|
Reason: container.Reason,
|
||||||
|
Message: container.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
conditions := make([]dto.InstanceConditionDiagnostics, 0, len(pod.Conditions))
|
||||||
|
for _, condition := range pod.Conditions {
|
||||||
|
conditions = append(conditions, dto.InstanceConditionDiagnostics{
|
||||||
|
Type: condition.Type,
|
||||||
|
Status: condition.Status,
|
||||||
|
Reason: condition.Reason,
|
||||||
|
Message: condition.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pods = append(pods, dto.InstancePodDiagnostics{
|
||||||
|
Name: pod.Name,
|
||||||
|
Namespace: pod.Namespace,
|
||||||
|
Phase: pod.Phase,
|
||||||
|
NodeName: pod.NodeName,
|
||||||
|
PodIP: pod.PodIP,
|
||||||
|
HostIP: pod.HostIP,
|
||||||
|
RestartCount: pod.RestartCount,
|
||||||
|
Containers: containers,
|
||||||
|
Conditions: conditions,
|
||||||
|
CreationTimestamp: formatTime(pod.CreationTimestamp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
services := make([]dto.InstanceServiceDiagnostics, 0, len(diagnostics.Services))
|
||||||
|
for _, svc := range diagnostics.Services {
|
||||||
|
ports := make([]dto.InstanceEntryPortResponse, 0, len(svc.Ports))
|
||||||
|
for _, port := range svc.Ports {
|
||||||
|
ports = append(ports, dto.InstanceEntryPortResponse{
|
||||||
|
Name: port.Name,
|
||||||
|
Protocol: port.Protocol,
|
||||||
|
Port: port.Port,
|
||||||
|
TargetPort: port.TargetPort,
|
||||||
|
NodePort: port.NodePort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
services = append(services, dto.InstanceServiceDiagnostics{
|
||||||
|
Name: svc.Name,
|
||||||
|
Namespace: svc.Namespace,
|
||||||
|
Type: svc.Type,
|
||||||
|
ClusterIP: svc.ClusterIP,
|
||||||
|
Ports: ports,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
events := make([]dto.InstanceEventDiagnostics, 0, len(diagnostics.Events))
|
||||||
|
for _, event := range diagnostics.Events {
|
||||||
|
events = append(events, dto.InstanceEventDiagnostics{
|
||||||
|
Type: event.Type,
|
||||||
|
Reason: event.Reason,
|
||||||
|
Message: event.Message,
|
||||||
|
InvolvedKind: event.InvolvedKind,
|
||||||
|
InvolvedName: event.InvolvedName,
|
||||||
|
Count: event.Count,
|
||||||
|
FirstTimestamp: formatTime(event.FirstTimestamp),
|
||||||
|
LastTimestamp: formatTime(event.LastTimestamp),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logs := make([]dto.InstancePodLogResponse, 0, len(diagnostics.Logs))
|
||||||
|
for _, logEntry := range diagnostics.Logs {
|
||||||
|
logs = append(logs, dto.InstancePodLogResponse{
|
||||||
|
Pod: logEntry.Pod,
|
||||||
|
Container: logEntry.Container,
|
||||||
|
TailLines: logEntry.TailLines,
|
||||||
|
Log: logEntry.Log,
|
||||||
|
Error: logEntry.Error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &dto.InstanceDiagnosticsResponse{
|
||||||
|
InstanceName: diagnostics.InstanceName,
|
||||||
|
Namespace: diagnostics.Namespace,
|
||||||
|
Pods: pods,
|
||||||
|
Services: services,
|
||||||
|
Events: events,
|
||||||
|
Logs: logs,
|
||||||
|
CollectedAt: formatTime(diagnostics.CollectedAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTime(value time.Time) string {
|
||||||
|
if value.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto.InstanceResponse {
|
||||||
|
response := &dto.InstanceResponse{
|
||||||
|
ID: instance.ID,
|
||||||
|
ClusterID: instance.ClusterID,
|
||||||
|
Name: instance.Name,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
RegistryID: instance.RegistryID,
|
||||||
|
Repository: instance.Repository,
|
||||||
|
Chart: instance.Chart,
|
||||||
|
Version: instance.Version,
|
||||||
|
Description: instance.Description,
|
||||||
|
Status: string(instance.Status),
|
||||||
|
WorkspaceID: instance.WorkspaceID,
|
||||||
|
OwnerID: instance.OwnerID,
|
||||||
|
StatusReason: instance.StatusReason,
|
||||||
|
LastOperation: string(instance.LastOperation),
|
||||||
|
LastError: instance.LastError,
|
||||||
|
Revision: instance.Revision,
|
||||||
|
Replicas: instance.Replicas,
|
||||||
|
AllowedActions: []string{"view", "update", "delete"},
|
||||||
|
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
}
|
||||||
|
if includeValues {
|
||||||
|
response.Values = instance.Values
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseValuesYAML(valuesYAML string) (map[string]interface{}, error) {
|
||||||
|
valuesYAML = strings.TrimSpace(valuesYAML)
|
||||||
|
if valuesYAML == "" {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded interface{}
|
||||||
|
if err := yaml.Unmarshal([]byte(valuesYAML), &decoded); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := normalizeYAMLValue(decoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values, ok := normalized.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("values YAML must be a mapping at the top level")
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeYAMLValue(value interface{}) (interface{}, error) {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
normalized := make(map[string]interface{}, len(typed))
|
||||||
|
for key, child := range typed {
|
||||||
|
normalizedChild, err := normalizeYAMLValue(child)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized[key] = normalizedChild
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
case map[interface{}]interface{}:
|
||||||
|
normalized := make(map[string]interface{}, len(typed))
|
||||||
|
for key, child := range typed {
|
||||||
|
keyString, ok := key.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("values YAML contains non-string key %v", key)
|
||||||
|
}
|
||||||
|
normalizedChild, err := normalizeYAMLValue(child)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized[keyString] = normalizedChild
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
case []interface{}:
|
||||||
|
normalized := make([]interface{}, 0, len(typed))
|
||||||
|
for _, child := range typed {
|
||||||
|
normalizedChild, err := normalizeYAMLValue(child)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
normalized = append(normalized, normalizedChild)
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
default:
|
||||||
|
return typed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -44,6 +44,10 @@ func (h *RegistryHandler) CreateRegistry(w http.ResponseWriter, r *http.Request)
|
|||||||
registry := entity.NewRegistry(req.Name, req.URL)
|
registry := entity.NewRegistry(req.Name, req.URL)
|
||||||
registry.Description = req.Description
|
registry.Description = req.Description
|
||||||
registry.Insecure = req.Insecure
|
registry.Insecure = req.Insecure
|
||||||
|
registry.Visibility = req.Visibility
|
||||||
|
if req.GlobalShared || req.GlobalSharedAlt {
|
||||||
|
registry.Visibility = "global_shared"
|
||||||
|
}
|
||||||
registry.SetCredentials(req.Username, req.Password)
|
registry.SetCredentials(req.Username, req.Password)
|
||||||
|
|
||||||
// 调用领域服务
|
// 调用领域服务
|
||||||
@ -136,6 +140,12 @@ func (h *RegistryHandler) UpdateRegistry(w http.ResponseWriter, r *http.Request)
|
|||||||
// 更新字段
|
// 更新字段
|
||||||
registry.Update(req.Name, req.URL, req.Description)
|
registry.Update(req.Name, req.URL, req.Description)
|
||||||
registry.Insecure = req.Insecure
|
registry.Insecure = req.Insecure
|
||||||
|
if req.Visibility != "" {
|
||||||
|
registry.Visibility = req.Visibility
|
||||||
|
}
|
||||||
|
if req.GlobalShared || req.GlobalSharedAlt {
|
||||||
|
registry.Visibility = "global_shared"
|
||||||
|
}
|
||||||
if req.Username != "" || req.Password != "" {
|
if req.Username != "" || req.Password != "" {
|
||||||
registry.SetCredentials(req.Username, req.Password)
|
registry.SetCredentials(req.Username, req.Password)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package rest
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -32,4 +32,3 @@ func respondSuccess(w http.ResponseWriter, message string, data interface{}) {
|
|||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, response)
|
respondJSON(w, http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
165
backend/internal/adapter/input/http/rest/workspace_handler.go
Normal file
165
backend/internal/adapter/input/http/rest/workspace_handler.go
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
package rest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceHandler struct {
|
||||||
|
workspaceService *service.WorkspaceService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkspaceHandler(workspaceService *service.WorkspaceService) *WorkspaceHandler {
|
||||||
|
return &WorkspaceHandler{workspaceService: workspaceService}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createWorkspaceRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type workspaceResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
K8sNamespace string `json:"k8sNamespace"`
|
||||||
|
K8sSAName string `json:"k8sSaName"`
|
||||||
|
DefaultClusterID string `json:"defaultClusterId,omitempty"`
|
||||||
|
QuotaCPU string `json:"quotaCpu,omitempty"`
|
||||||
|
QuotaMemory string `json:"quotaMemory,omitempty"`
|
||||||
|
QuotaGPU string `json:"quotaGpu,omitempty"`
|
||||||
|
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bindClusterRequest struct {
|
||||||
|
ClusterID string `json:"clusterId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type kubeconfigRequest struct {
|
||||||
|
ClusterID string `json:"clusterId"`
|
||||||
|
TTLSeconds int64 `json:"ttlSeconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
|
||||||
|
workspaces, err := h.workspaceService.ListWorkspaces(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to list workspaces")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response := make([]workspaceResponse, 0, len(workspaces))
|
||||||
|
for _, workspace := range workspaces {
|
||||||
|
response = append(response, toWorkspaceResponse(workspace))
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createWorkspaceRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
workspace, err := h.workspaceService.CreateWorkspace(r.Context(), req.Name)
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to create workspace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusCreated, toWorkspaceResponse(workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) InitClusterBinding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
workspaceID := mux.Vars(r)["workspace_id"]
|
||||||
|
var req bindClusterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding, err := h.workspaceService.EnsureClusterBinding(r.Context(), workspaceID, req.ClusterID)
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to initialize workspace cluster binding")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) IssueKubeconfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
workspaceID := mux.Vars(r)["workspace_id"]
|
||||||
|
var req kubeconfigRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kubeconfig, err := h.workspaceService.IssueKubeconfig(r.Context(), workspaceID, req.ClusterID, time.Duration(req.TTLSeconds)*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to issue kubeconfig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"kubeconfig": kubeconfig.Kubeconfig,
|
||||||
|
"expiresAt": kubeconfig.ExpiresAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) IssueCurrentKubeconfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clusterID := r.URL.Query().Get("clusterId")
|
||||||
|
if clusterID == "" {
|
||||||
|
clusterID = r.URL.Query().Get("cluster_id")
|
||||||
|
}
|
||||||
|
kubeconfig, err := h.workspaceService.IssueCurrentKubeconfig(r.Context(), clusterID, 2*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to issue kubeconfig")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/x-yaml")
|
||||||
|
w.Header().Set("X-OCDP-Kubeconfig-Expires-At", kubeconfig.ExpiresAt.Format(time.RFC3339))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(kubeconfig.Kubeconfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WorkspaceHandler) SuspendWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||||
|
workspaceID := mux.Vars(r)["workspace_id"]
|
||||||
|
if err := h.workspaceService.SuspendWorkspace(r.Context(), workspaceID); err != nil {
|
||||||
|
respondServiceError(w, err, "Failed to suspend workspace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toWorkspaceResponse(workspace *entity.Workspace) workspaceResponse {
|
||||||
|
return workspaceResponse{
|
||||||
|
ID: workspace.ID,
|
||||||
|
Name: workspace.Name,
|
||||||
|
Status: string(workspace.Status),
|
||||||
|
K8sNamespace: workspace.K8sNamespace,
|
||||||
|
K8sSAName: workspace.K8sSAName,
|
||||||
|
DefaultClusterID: workspace.DefaultClusterID,
|
||||||
|
QuotaCPU: workspace.QuotaCPU,
|
||||||
|
QuotaMemory: workspace.QuotaMemory,
|
||||||
|
QuotaGPU: workspace.QuotaGPU,
|
||||||
|
QuotaGPUMem: workspace.QuotaGPUMem,
|
||||||
|
CreatedBy: workspace.CreatedBy,
|
||||||
|
CreatedAt: workspace.CreatedAt.Format(time.RFC3339),
|
||||||
|
UpdatedAt: workspace.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respondServiceError(w http.ResponseWriter, err error, fallback string) {
|
||||||
|
switch err {
|
||||||
|
case entity.ErrUnauthorized, authz.ErrUnauthenticated:
|
||||||
|
respondError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||||
|
case entity.ErrForbidden, authz.ErrForbidden, entity.ErrUserInactive, entity.ErrWorkspaceSuspended:
|
||||||
|
respondError(w, http.StatusForbidden, "Forbidden", err.Error())
|
||||||
|
case entity.ErrClusterNotFound, entity.ErrRegistryNotFound, entity.ErrInstanceNotFound, entity.ErrWorkspaceNotFound:
|
||||||
|
respondError(w, http.StatusNotFound, fallback, err.Error())
|
||||||
|
default:
|
||||||
|
respondError(w, http.StatusBadRequest, fallback, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -96,6 +96,36 @@ func (f *AdapterFactory) CreateInstanceRepository() (repository.InstanceReposito
|
|||||||
return postgres.NewInstanceRepository(f.db), nil
|
return postgres.NewInstanceRepository(f.db), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *AdapterFactory) CreateWorkspaceRepository() (repository.WorkspaceRepository, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return mock.NewWorkspaceRepositoryMock(), nil
|
||||||
|
}
|
||||||
|
if err := f.ensureDBConnection(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return postgres.NewWorkspaceRepository(f.db), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *AdapterFactory) CreateWorkspaceClusterBindingRepository() (repository.WorkspaceClusterBindingRepository, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return mock.NewWorkspaceClusterBindingRepositoryMock(), nil
|
||||||
|
}
|
||||||
|
if err := f.ensureDBConnection(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return postgres.NewWorkspaceClusterBindingRepository(f.db), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *AdapterFactory) CreateAuditLogRepository() (repository.AuditLogRepository, error) {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return mock.NewAuditLogRepositoryMock(), nil
|
||||||
|
}
|
||||||
|
if err := f.ensureDBConnection(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return postgres.NewAuditLogRepository(f.db), nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateOCIClient 创建 OCI 客户端
|
// CreateOCIClient 创建 OCI 客户端
|
||||||
func (f *AdapterFactory) CreateOCIClient() (repository.OCIClient, error) {
|
func (f *AdapterFactory) CreateOCIClient() (repository.OCIClient, error) {
|
||||||
if f.mode == ModeMock {
|
if f.mode == ModeMock {
|
||||||
@ -127,6 +157,20 @@ func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
|
|||||||
return k8s.NewEntryClient()
|
return k8s.NewEntryClient()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *AdapterFactory) CreateDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return k8s.NewMockDiagnosticsClient()
|
||||||
|
}
|
||||||
|
return k8s.NewDiagnosticsClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *AdapterFactory) CreateTenantKubeClient() repository.TenantKubeClient {
|
||||||
|
if f.mode == ModeMock {
|
||||||
|
return k8s.NewMockTenantClient()
|
||||||
|
}
|
||||||
|
return k8s.NewTenantClient()
|
||||||
|
}
|
||||||
|
|
||||||
// CreateAllRepositories 一次性创建所有 Repositories
|
// CreateAllRepositories 一次性创建所有 Repositories
|
||||||
func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
||||||
userRepo, err := f.CreateUserRepository()
|
userRepo, err := f.CreateUserRepository()
|
||||||
@ -149,6 +193,21 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
|||||||
return nil, fmt.Errorf("failed to create instance repository: %w", err)
|
return nil, fmt.Errorf("failed to create instance repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workspaceRepo, err := f.CreateWorkspaceRepository()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create workspace repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bindingRepo, err := f.CreateWorkspaceClusterBindingRepository()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create workspace cluster binding repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
auditRepo, err := f.CreateAuditLogRepository()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create audit log repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
ociClient, err := f.CreateOCIClient()
|
ociClient, err := f.CreateOCIClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create OCI client: %w", err)
|
return nil, fmt.Errorf("failed to create OCI client: %w", err)
|
||||||
@ -162,29 +221,41 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
|||||||
// 创建 Metrics client(依赖 clusterRepo)
|
// 创建 Metrics client(依赖 clusterRepo)
|
||||||
metricsClient := f.CreateMetricsClient(clusterRepo)
|
metricsClient := f.CreateMetricsClient(clusterRepo)
|
||||||
entryClient := f.CreateEntryClient()
|
entryClient := f.CreateEntryClient()
|
||||||
|
diagnosticsClient := f.CreateDiagnosticsClient()
|
||||||
|
tenantClient := f.CreateTenantKubeClient()
|
||||||
|
|
||||||
return &Repositories{
|
return &Repositories{
|
||||||
UserRepo: userRepo,
|
UserRepo: userRepo,
|
||||||
ClusterRepo: clusterRepo,
|
WorkspaceRepo: workspaceRepo,
|
||||||
RegistryRepo: registryRepo,
|
BindingRepo: bindingRepo,
|
||||||
InstanceRepo: instanceRepo,
|
AuditRepo: auditRepo,
|
||||||
OCIClient: ociClient,
|
ClusterRepo: clusterRepo,
|
||||||
HelmClient: helmClient,
|
RegistryRepo: registryRepo,
|
||||||
MetricsClient: metricsClient,
|
InstanceRepo: instanceRepo,
|
||||||
EntryClient: entryClient,
|
OCIClient: ociClient,
|
||||||
|
HelmClient: helmClient,
|
||||||
|
MetricsClient: metricsClient,
|
||||||
|
EntryClient: entryClient,
|
||||||
|
DiagnosticsClient: diagnosticsClient,
|
||||||
|
TenantKubeClient: tenantClient,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repositories 所有仓储的集合
|
// Repositories 所有仓储的集合
|
||||||
type Repositories struct {
|
type Repositories struct {
|
||||||
UserRepo repository.UserRepository
|
UserRepo repository.UserRepository
|
||||||
ClusterRepo repository.ClusterRepository
|
WorkspaceRepo repository.WorkspaceRepository
|
||||||
RegistryRepo repository.RegistryRepository
|
BindingRepo repository.WorkspaceClusterBindingRepository
|
||||||
InstanceRepo repository.InstanceRepository
|
AuditRepo repository.AuditLogRepository
|
||||||
OCIClient repository.OCIClient
|
ClusterRepo repository.ClusterRepository
|
||||||
HelmClient repository.HelmClient
|
RegistryRepo repository.RegistryRepository
|
||||||
MetricsClient repository.MetricsClient
|
InstanceRepo repository.InstanceRepository
|
||||||
EntryClient repository.InstanceEntryClient
|
OCIClient repository.OCIClient
|
||||||
|
HelmClient repository.HelmClient
|
||||||
|
MetricsClient repository.MetricsClient
|
||||||
|
EntryClient repository.InstanceEntryClient
|
||||||
|
DiagnosticsClient repository.InstanceDiagnosticsClient
|
||||||
|
TenantKubeClient repository.TenantKubeClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureDBConnection 确保数据库连接已建立
|
// ensureDBConnection 确保数据库连接已建立
|
||||||
|
|||||||
@ -194,3 +194,13 @@ func (c *HelmClientMock) GetValues(ctx context.Context, cluster *entity.Cluster,
|
|||||||
return instance.Values, nil
|
return instance.Values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *HelmClientMock) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"replicaCount": 1,
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"repository": "nginx",
|
||||||
|
"tag": "latest",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import (
|
|||||||
"k8s.io/client-go/rest"
|
"k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/restmapper"
|
"k8s.io/client-go/restmapper"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HelmClient 真实的 Helm 客户端实现
|
// HelmClient 真实的 Helm 客户端实现
|
||||||
@ -36,39 +37,45 @@ func NewHelmClient() repository.HelmClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getActionConfig 获取 Helm action configuration
|
// getActionConfig 获取 Helm action configuration
|
||||||
func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, error) {
|
func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, func(), error) {
|
||||||
actionConfig := new(action.Configuration)
|
actionConfig := new(action.Configuration)
|
||||||
|
|
||||||
// 创建临时 kubeconfig 文件
|
// 创建临时 kubeconfig 文件
|
||||||
kubeconfigContent := cluster.GetKubeConfig()
|
kubeconfigContent := cluster.GetKubeConfig()
|
||||||
tmpDir, err := os.MkdirTemp("", "helm-kubeconfig-*")
|
tmpDir, err := os.MkdirTemp("", "helm-kubeconfig-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
return nil, nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
cleanup := func() {
|
||||||
|
_ = os.RemoveAll(tmpDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
kubeconfigPath := filepath.Join(tmpDir, "kubeconfig")
|
kubeconfigPath := filepath.Join(tmpDir, "kubeconfig")
|
||||||
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil {
|
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write kubeconfig: %w", err)
|
cleanup()
|
||||||
|
return nil, nil, fmt.Errorf("failed to write kubeconfig: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 kubeconfig 初始化 action config
|
// 使用 kubeconfig 初始化 action config
|
||||||
if err := actionConfig.Init(
|
if err := actionConfig.Init(
|
||||||
&kubeconfigGetter{kubeconfigPath: kubeconfigPath},
|
&kubeconfigGetter{kubeconfigPath: kubeconfigPath, namespace: namespace},
|
||||||
namespace,
|
namespace,
|
||||||
os.Getenv("HELM_DRIVER"), // storage driver: configmap, secret, memory
|
os.Getenv("HELM_DRIVER"), // storage driver: configmap, secret, memory
|
||||||
func(format string, v ...interface{}) {
|
func(format string, v ...interface{}) {
|
||||||
// Log function
|
// Log function
|
||||||
},
|
},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("failed to initialize action config: %w", err)
|
cleanup()
|
||||||
|
return nil, nil, fmt.Errorf("failed to initialize action config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return actionConfig, nil
|
return actionConfig, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// kubeconfigGetter implements RESTClientGetter
|
// kubeconfigGetter implements RESTClientGetter
|
||||||
type kubeconfigGetter struct {
|
type kubeconfigGetter struct {
|
||||||
kubeconfigPath string
|
kubeconfigPath string
|
||||||
|
namespace string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *kubeconfigGetter) ToRESTConfig() (*rest.Config, error) {
|
func (k *kubeconfigGetter) ToRESTConfig() (*rest.Config, error) {
|
||||||
@ -95,25 +102,30 @@ func (k *kubeconfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *kubeconfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
|
func (k *kubeconfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
|
||||||
|
overrides := &clientcmd.ConfigOverrides{}
|
||||||
|
if k.namespace != "" {
|
||||||
|
overrides.Context = clientcmdapi.Context{Namespace: k.namespace}
|
||||||
|
}
|
||||||
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||||
&clientcmd.ClientConfigLoadingRules{ExplicitPath: k.kubeconfigPath},
|
&clientcmd.ClientConfigLoadingRules{ExplicitPath: k.kubeconfigPath},
|
||||||
&clientcmd.ConfigOverrides{},
|
overrides,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install 安装 Helm Chart
|
// Install 安装 Helm Chart
|
||||||
func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, instance.Namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
install := action.NewInstall(actionConfig)
|
install := action.NewInstall(actionConfig)
|
||||||
install.ReleaseName = instance.Name
|
install.ReleaseName = instance.Name
|
||||||
install.Namespace = instance.Namespace
|
install.Namespace = instance.Namespace
|
||||||
install.CreateNamespace = true
|
install.CreateNamespace = true
|
||||||
install.Wait = true
|
install.Wait = true
|
||||||
install.Timeout = 5 * time.Minute
|
install.Timeout = helmOperationTimeout()
|
||||||
|
|
||||||
// 加载 Chart(从本地路径或 OCI registry)
|
// 加载 Chart(从本地路径或 OCI registry)
|
||||||
// 这里简化处理,假设 chart 已经被拉取到本地
|
// 这里简化处理,假设 chart 已经被拉取到本地
|
||||||
@ -139,15 +151,17 @@ func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, insta
|
|||||||
|
|
||||||
// Upgrade 升级 Helm Release
|
// Upgrade 升级 Helm Release
|
||||||
func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, instance.Namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
upgrade := action.NewUpgrade(actionConfig)
|
upgrade := action.NewUpgrade(actionConfig)
|
||||||
upgrade.Namespace = instance.Namespace
|
upgrade.Namespace = instance.Namespace
|
||||||
|
upgrade.ReuseValues = true
|
||||||
upgrade.Wait = true
|
upgrade.Wait = true
|
||||||
upgrade.Timeout = 5 * time.Minute
|
upgrade.Timeout = helmOperationTimeout()
|
||||||
|
|
||||||
// 加载 Chart
|
// 加载 Chart
|
||||||
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
||||||
@ -172,14 +186,15 @@ func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, insta
|
|||||||
|
|
||||||
// Uninstall 卸载 Helm Release
|
// Uninstall 卸载 Helm Release
|
||||||
func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
uninstall := action.NewUninstall(actionConfig)
|
uninstall := action.NewUninstall(actionConfig)
|
||||||
uninstall.Wait = true
|
uninstall.Wait = true
|
||||||
uninstall.Timeout = 5 * time.Minute
|
uninstall.Timeout = helmOperationTimeout()
|
||||||
|
|
||||||
_, err = uninstall.Run(releaseName)
|
_, err = uninstall.Run(releaseName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -194,15 +209,16 @@ func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, rel
|
|||||||
|
|
||||||
// Rollback 回滚 Helm Release
|
// Rollback 回滚 Helm Release
|
||||||
func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
rollback := action.NewRollback(actionConfig)
|
rollback := action.NewRollback(actionConfig)
|
||||||
rollback.Version = revision
|
rollback.Version = revision
|
||||||
rollback.Wait = true
|
rollback.Wait = true
|
||||||
rollback.Timeout = 5 * time.Minute
|
rollback.Timeout = helmOperationTimeout()
|
||||||
|
|
||||||
if err := rollback.Run(releaseName); err != nil {
|
if err := rollback.Run(releaseName); err != nil {
|
||||||
return fmt.Errorf("failed to rollback release: %w", err)
|
return fmt.Errorf("failed to rollback release: %w", err)
|
||||||
@ -211,12 +227,25 @@ func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, rele
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func helmOperationTimeout() time.Duration {
|
||||||
|
raw := os.Getenv("HELM_OPERATION_TIMEOUT")
|
||||||
|
if raw == "" {
|
||||||
|
return 15 * time.Minute
|
||||||
|
}
|
||||||
|
timeout, err := time.ParseDuration(raw)
|
||||||
|
if err != nil || timeout <= 0 {
|
||||||
|
return 15 * time.Minute
|
||||||
|
}
|
||||||
|
return timeout
|
||||||
|
}
|
||||||
|
|
||||||
// GetStatus 获取 Release 状态
|
// GetStatus 获取 Release 状态
|
||||||
func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
status := action.NewStatus(actionConfig)
|
status := action.NewStatus(actionConfig)
|
||||||
rel, err := status.Run(releaseName)
|
rel, err := status.Run(releaseName)
|
||||||
@ -229,10 +258,11 @@ func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, rel
|
|||||||
|
|
||||||
// GetHistory 获取 Release 历史
|
// GetHistory 获取 Release 历史
|
||||||
func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
history := action.NewHistory(actionConfig)
|
history := action.NewHistory(actionConfig)
|
||||||
history.Max = 256
|
history.Max = 256
|
||||||
@ -259,10 +289,11 @@ func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, re
|
|||||||
|
|
||||||
// List 列出集群中的所有 Releases
|
// List 列出集群中的所有 Releases
|
||||||
func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
list := action.NewList(actionConfig)
|
list := action.NewList(actionConfig)
|
||||||
if namespace == "" {
|
if namespace == "" {
|
||||||
@ -284,12 +315,14 @@ func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespac
|
|||||||
|
|
||||||
// GetValues 获取 Release 的 values
|
// GetValues 获取 Release 的 values
|
||||||
func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
actionConfig, cleanup, err := h.getActionConfig(cluster, namespace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
getValues := action.NewGetValues(actionConfig)
|
getValues := action.NewGetValues(actionConfig)
|
||||||
|
getValues.AllValues = true
|
||||||
values, err := getValues.Run(releaseName)
|
values, err := getValues.Run(releaseName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get values: %w", err)
|
return nil, fmt.Errorf("failed to get values: %w", err)
|
||||||
@ -298,6 +331,21 @@ func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, rel
|
|||||||
return values, nil
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetChartDefaultValues 从 chart 包中读取默认 values
|
||||||
|
func (h *HelmClient) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) {
|
||||||
|
chart, err := loader.Load(chartPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load chart: %w", err)
|
||||||
|
}
|
||||||
|
vals := make(map[string]interface{})
|
||||||
|
if chart.Values != nil {
|
||||||
|
for k, v := range chart.Values {
|
||||||
|
vals[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vals, nil
|
||||||
|
}
|
||||||
|
|
||||||
// convertReleaseToInstance 转换 Helm Release 为 Instance
|
// convertReleaseToInstance 转换 Helm Release 为 Instance
|
||||||
func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance {
|
func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance {
|
||||||
return &entity.Instance{
|
return &entity.Instance{
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
package real
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKubeconfigGetterOverridesNamespace(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
kubeconfigPath := filepath.Join(t.TempDir(), "kubeconfig")
|
||||||
|
kubeconfig := `apiVersion: v1
|
||||||
|
kind: Config
|
||||||
|
clusters:
|
||||||
|
- cluster:
|
||||||
|
server: https://127.0.0.1:6443
|
||||||
|
name: test
|
||||||
|
contexts:
|
||||||
|
- context:
|
||||||
|
cluster: test
|
||||||
|
user: test
|
||||||
|
name: test
|
||||||
|
current-context: test
|
||||||
|
users:
|
||||||
|
- name: test
|
||||||
|
user:
|
||||||
|
token: test
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfig), 0600); err != nil {
|
||||||
|
t.Fatalf("failed to write kubeconfig: %v", err)
|
||||||
|
}
|
||||||
|
getter := &kubeconfigGetter{
|
||||||
|
kubeconfigPath: kubeconfigPath,
|
||||||
|
namespace: "ocdp-u-alice",
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace, _, err := getter.ToRawKubeConfigLoader().Namespace()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Namespace returned error: %v", err)
|
||||||
|
}
|
||||||
|
if namespace != "ocdp-u-alice" {
|
||||||
|
t.Fatalf("expected namespace override %q, got %q", "ocdp-u-alice", namespace)
|
||||||
|
}
|
||||||
|
}
|
||||||
374
backend/internal/adapter/output/k8s/diagnostics_client.go
Normal file
374
backend/internal/adapter/output/k8s/diagnostics_client.go
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DiagnosticsClient struct{}
|
||||||
|
|
||||||
|
func NewDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||||
|
return &DiagnosticsClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockDiagnosticsClient struct{}
|
||||||
|
|
||||||
|
func NewMockDiagnosticsClient() repository.InstanceDiagnosticsClient {
|
||||||
|
return &MockDiagnosticsClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MockDiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||||
|
return &entity.InstanceDiagnostics{
|
||||||
|
InstanceName: instance.Name,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
CollectedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MockDiagnosticsClient) StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
|
||||||
|
lines := make(chan string, 10)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(lines)
|
||||||
|
defer close(errs)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case lines <- "[mock] Streaming pod logs...":
|
||||||
|
case lines <- "[mock] Container started successfully":
|
||||||
|
case lines <- "[mock] Listening on :8080":
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return lines, errs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiagnosticsClient) GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||||
|
clientset, err := diagnosticsClientset(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tailLines <= 0 {
|
||||||
|
tailLines = 200
|
||||||
|
}
|
||||||
|
if tailLines > 2000 {
|
||||||
|
tailLines = 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
pods, err := listInstancePods(ctx, clientset, instance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
services, err := listInstanceServices(ctx, clientset, instance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
events, err := listInstanceEvents(ctx, clientset, instance, pods, services)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs := collectPodLogs(ctx, clientset, pods, tailLines)
|
||||||
|
|
||||||
|
return &entity.InstanceDiagnostics{
|
||||||
|
InstanceName: instance.Name,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
Pods: convertPodsToDiagnostics(pods),
|
||||||
|
Services: convertServicesToDiagnostics(services),
|
||||||
|
Events: convertEventsToDiagnostics(events),
|
||||||
|
Logs: logs,
|
||||||
|
CollectedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *DiagnosticsClient) StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
|
||||||
|
clientset, err := diagnosticsClientset(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if tailLines <= 0 {
|
||||||
|
tailLines = 200
|
||||||
|
}
|
||||||
|
if tailLines > 2000 {
|
||||||
|
tailLines = 2000
|
||||||
|
}
|
||||||
|
|
||||||
|
req := clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
|
||||||
|
Container: containerName,
|
||||||
|
Follow: true,
|
||||||
|
TailLines: &tailLines,
|
||||||
|
})
|
||||||
|
|
||||||
|
stream, err := req.Stream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to open log stream for %s/%s: %w", podName, containerName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := make(chan string, 64)
|
||||||
|
errs := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(lines)
|
||||||
|
defer close(errs)
|
||||||
|
defer func() { _ = stream.Close() }()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(stream)
|
||||||
|
// Allow long lines; Kubernetes log entries can exceed the default 64 KiB
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case lines <- line:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
select {
|
||||||
|
case errs <- err:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return lines, errs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticsClientset(cluster *entity.Cluster) (kubernetes.Interface, error) {
|
||||||
|
config, err := restConfigFromCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create diagnostics kubernetes client: %w", err)
|
||||||
|
}
|
||||||
|
return clientset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listInstancePods(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance) ([]corev1.Pod, error) {
|
||||||
|
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||||
|
pods, err := clientset.CoreV1().Pods(instance.Namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list instance pods: %w", err)
|
||||||
|
}
|
||||||
|
if len(pods.Items) > 0 {
|
||||||
|
return pods.Items, nil
|
||||||
|
}
|
||||||
|
all, err := clientset.CoreV1().Pods(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list namespace pods: %w", err)
|
||||||
|
}
|
||||||
|
filtered := make([]corev1.Pod, 0)
|
||||||
|
for _, pod := range all.Items {
|
||||||
|
if resourceMatchesInstance(pod.ObjectMeta, instance) {
|
||||||
|
filtered = append(filtered, pod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listInstanceServices(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance) ([]corev1.Service, error) {
|
||||||
|
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||||
|
services, err := clientset.CoreV1().Services(instance.Namespace).List(ctx, metav1.ListOptions{LabelSelector: selector})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list instance services: %w", err)
|
||||||
|
}
|
||||||
|
if len(services.Items) > 0 {
|
||||||
|
return services.Items, nil
|
||||||
|
}
|
||||||
|
all, err := clientset.CoreV1().Services(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list namespace services: %w", err)
|
||||||
|
}
|
||||||
|
filtered := make([]corev1.Service, 0)
|
||||||
|
for _, svc := range all.Items {
|
||||||
|
if resourceMatchesInstance(svc.ObjectMeta, instance) {
|
||||||
|
filtered = append(filtered, svc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listInstanceEvents(ctx context.Context, clientset kubernetes.Interface, instance *entity.Instance, pods []corev1.Pod, services []corev1.Service) ([]corev1.Event, error) {
|
||||||
|
events, err := clientset.CoreV1().Events(instance.Namespace).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list instance events: %w", err)
|
||||||
|
}
|
||||||
|
names := map[string]bool{instance.Name: true}
|
||||||
|
for _, pod := range pods {
|
||||||
|
names[pod.Name] = true
|
||||||
|
}
|
||||||
|
for _, svc := range services {
|
||||||
|
names[svc.Name] = true
|
||||||
|
}
|
||||||
|
filtered := make([]corev1.Event, 0)
|
||||||
|
for _, event := range events.Items {
|
||||||
|
if names[event.InvolvedObject.Name] || strings.Contains(event.Message, instance.Name) {
|
||||||
|
filtered = append(filtered, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(filtered, func(i, j int) bool {
|
||||||
|
return filtered[i].LastTimestamp.Time.After(filtered[j].LastTimestamp.Time)
|
||||||
|
})
|
||||||
|
if len(filtered) > 100 {
|
||||||
|
filtered = filtered[:100]
|
||||||
|
}
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPodLogs(ctx context.Context, clientset kubernetes.Interface, pods []corev1.Pod, tailLines int64) []entity.InstancePodLog {
|
||||||
|
logs := make([]entity.InstancePodLog, 0)
|
||||||
|
for _, pod := range pods {
|
||||||
|
for _, container := range pod.Spec.Containers {
|
||||||
|
item := entity.InstancePodLog{Pod: pod.Name, Container: container.Name, TailLines: tailLines}
|
||||||
|
req := clientset.CoreV1().Pods(pod.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{
|
||||||
|
Container: container.Name,
|
||||||
|
TailLines: &tailLines,
|
||||||
|
})
|
||||||
|
stream, err := req.Stream(ctx)
|
||||||
|
if err != nil {
|
||||||
|
item.Error = err.Error()
|
||||||
|
logs = append(logs, item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(stream, 1<<20))
|
||||||
|
_ = stream.Close()
|
||||||
|
if err != nil {
|
||||||
|
item.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
item.Log = string(data)
|
||||||
|
}
|
||||||
|
logs = append(logs, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPodsToDiagnostics(pods []corev1.Pod) []entity.InstancePodDiagnostics {
|
||||||
|
out := make([]entity.InstancePodDiagnostics, 0, len(pods))
|
||||||
|
for _, pod := range pods {
|
||||||
|
containers := make([]entity.InstanceContainerDiagnostics, 0, len(pod.Status.ContainerStatuses))
|
||||||
|
var restarts int32
|
||||||
|
for _, status := range pod.Status.ContainerStatuses {
|
||||||
|
restarts += status.RestartCount
|
||||||
|
containers = append(containers, entity.InstanceContainerDiagnostics{
|
||||||
|
Name: status.Name,
|
||||||
|
Image: status.Image,
|
||||||
|
Ready: status.Ready,
|
||||||
|
RestartCount: status.RestartCount,
|
||||||
|
State: containerStateName(status.State),
|
||||||
|
Reason: containerStateReason(status.State),
|
||||||
|
Message: containerStateMessage(status.State),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
conditions := make([]entity.InstanceConditionDiagnostics, 0, len(pod.Status.Conditions))
|
||||||
|
for _, condition := range pod.Status.Conditions {
|
||||||
|
conditions = append(conditions, entity.InstanceConditionDiagnostics{
|
||||||
|
Type: string(condition.Type),
|
||||||
|
Status: string(condition.Status),
|
||||||
|
Reason: condition.Reason,
|
||||||
|
Message: condition.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, entity.InstancePodDiagnostics{
|
||||||
|
Name: pod.Name,
|
||||||
|
Namespace: pod.Namespace,
|
||||||
|
Phase: string(pod.Status.Phase),
|
||||||
|
NodeName: pod.Spec.NodeName,
|
||||||
|
PodIP: pod.Status.PodIP,
|
||||||
|
HostIP: pod.Status.HostIP,
|
||||||
|
RestartCount: restarts,
|
||||||
|
Containers: containers,
|
||||||
|
Conditions: conditions,
|
||||||
|
CreationTimestamp: pod.CreationTimestamp.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertServicesToDiagnostics(services []corev1.Service) []entity.InstanceServiceDiagnostics {
|
||||||
|
out := make([]entity.InstanceServiceDiagnostics, 0, len(services))
|
||||||
|
for _, svc := range services {
|
||||||
|
entry := convertServiceToEntry(&svc)
|
||||||
|
out = append(out, entity.InstanceServiceDiagnostics{
|
||||||
|
Name: svc.Name,
|
||||||
|
Namespace: svc.Namespace,
|
||||||
|
Type: string(svc.Spec.Type),
|
||||||
|
ClusterIP: svc.Spec.ClusterIP,
|
||||||
|
Ports: entry.Ports,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertEventsToDiagnostics(events []corev1.Event) []entity.InstanceEventDiagnostics {
|
||||||
|
out := make([]entity.InstanceEventDiagnostics, 0, len(events))
|
||||||
|
for _, event := range events {
|
||||||
|
out = append(out, entity.InstanceEventDiagnostics{
|
||||||
|
Type: event.Type,
|
||||||
|
Reason: event.Reason,
|
||||||
|
Message: event.Message,
|
||||||
|
InvolvedKind: event.InvolvedObject.Kind,
|
||||||
|
InvolvedName: event.InvolvedObject.Name,
|
||||||
|
Count: event.Count,
|
||||||
|
FirstTimestamp: event.FirstTimestamp.Time,
|
||||||
|
LastTimestamp: event.LastTimestamp.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerStateName(state corev1.ContainerState) string {
|
||||||
|
switch {
|
||||||
|
case state.Running != nil:
|
||||||
|
return "running"
|
||||||
|
case state.Waiting != nil:
|
||||||
|
return "waiting"
|
||||||
|
case state.Terminated != nil:
|
||||||
|
return "terminated"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerStateReason(state corev1.ContainerState) string {
|
||||||
|
switch {
|
||||||
|
case state.Waiting != nil:
|
||||||
|
return state.Waiting.Reason
|
||||||
|
case state.Terminated != nil:
|
||||||
|
return state.Terminated.Reason
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerStateMessage(state corev1.ContainerState) string {
|
||||||
|
switch {
|
||||||
|
case state.Waiting != nil:
|
||||||
|
return state.Waiting.Message
|
||||||
|
case state.Terminated != nil:
|
||||||
|
return state.Terminated.Message
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
134
backend/internal/adapter/output/k8s/scale_client.go
Normal file
134
backend/internal/adapter/output/k8s/scale_client.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScaleClient provides K8s-native workload scaling (bypasses Helm)
|
||||||
|
type ScaleClient struct{}
|
||||||
|
|
||||||
|
// NewScaleClient creates a ScaleClient
|
||||||
|
func NewScaleClient() *ScaleClient {
|
||||||
|
return &ScaleClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findDeployment searches for a deployment matching the release name using various label strategies.
|
||||||
|
func (c *ScaleClient) findDeployment(ctx context.Context, clientset *kubernetes.Clientset, namespace, releaseName string) (*appsv1.Deployment, error) {
|
||||||
|
labelQueries := []string{
|
||||||
|
fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName),
|
||||||
|
fmt.Sprintf("release=%s", releaseName),
|
||||||
|
fmt.Sprintf("app=%s", releaseName),
|
||||||
|
fmt.Sprintf("app.kubernetes.io/name=%s", releaseName),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, query := range labelQueries {
|
||||||
|
deployments, err := clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: query,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(deployments.Items) > 0 {
|
||||||
|
return &deployments.Items[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: get by name directly
|
||||||
|
dep, err := clientset.AppsV1().Deployments(namespace).Get(ctx, releaseName, metav1.GetOptions{})
|
||||||
|
if err == nil && dep != nil {
|
||||||
|
return dep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeploymentReplicas returns the current replicas count for a deployment.
|
||||||
|
func (c *ScaleClient) GetDeploymentReplicas(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string) (int32, error) {
|
||||||
|
clientset, err := c.clientsetForCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to create k8s client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dep, err := c.findDeployment(ctx, clientset, namespace, releaseName)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if dep != nil && dep.Spec.Replicas != nil {
|
||||||
|
return *dep.Spec.Replicas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to statefulsets
|
||||||
|
return c.getStatefulSetReplicas(ctx, clientset, namespace, releaseName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScaleClient) getStatefulSetReplicas(ctx context.Context, clientset *kubernetes.Clientset, namespace, releaseName string) (int32, error) {
|
||||||
|
stsList, err := clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(stsList.Items) == 0 {
|
||||||
|
return 0, nil // No replicable workload found
|
||||||
|
}
|
||||||
|
sts := stsList.Items[0]
|
||||||
|
if sts.Spec.Replicas != nil {
|
||||||
|
return *sts.Spec.Replicas, nil
|
||||||
|
}
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleDeployment scales the K8s deployment directly (bypasses Helm).
|
||||||
|
func (c *ScaleClient) ScaleDeployment(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string, replicas int32) error {
|
||||||
|
clientset, err := c.clientsetForCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create k8s client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dep, err := c.findDeployment(ctx, clientset, namespace, releaseName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dep != nil {
|
||||||
|
dep.Spec.Replicas = &replicas
|
||||||
|
_, err = clientset.AppsV1().Deployments(namespace).Update(ctx, dep, metav1.UpdateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to scale deployment %s: %w", dep.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try StatefulSets
|
||||||
|
stsList, err := clientset.AppsV1().StatefulSets(namespace).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: fmt.Sprintf("app.kubernetes.io/instance=%s", releaseName),
|
||||||
|
})
|
||||||
|
if err == nil && len(stsList.Items) > 0 {
|
||||||
|
sts := stsList.Items[0]
|
||||||
|
sts.Spec.Replicas = &replicas
|
||||||
|
_, err = clientset.AppsV1().StatefulSets(namespace).Update(ctx, &sts, metav1.UpdateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to scale statefulset %s: %w", sts.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("no deployment or statefulset found for release %s in namespace %s", releaseName, namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ScaleClient) clientsetForCluster(cluster *entity.Cluster) (*kubernetes.Clientset, error) {
|
||||||
|
restConfig, err := restConfigFromCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create rest config: %w", err)
|
||||||
|
}
|
||||||
|
clientset, err := kubernetes.NewForConfig(restConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create clientset: %w", err)
|
||||||
|
}
|
||||||
|
return clientset, nil
|
||||||
|
}
|
||||||
388
backend/internal/adapter/output/k8s/tenant_client.go
Normal file
388
backend/internal/adapter/output/k8s/tenant_client.go
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authenticationv1 "k8s.io/api/authentication/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
|
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantClient provisions namespace-scoped tenant Kubernetes resources.
|
||||||
|
type TenantClient struct {
|
||||||
|
clientset kubernetes.Interface
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTenantClient creates a tenant provisioning client that builds Kubernetes
|
||||||
|
// clients from the supplied cluster entity for each call.
|
||||||
|
func NewTenantClient() repository.TenantKubeClient {
|
||||||
|
return &TenantClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTenantClientForClientset creates a tenant provisioning client for tests or
|
||||||
|
// callers that already own a Kubernetes client.
|
||||||
|
func NewTenantClientForClientset(clientset kubernetes.Interface) repository.TenantKubeClient {
|
||||||
|
return &TenantClient{clientset: clientset}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureTenant idempotently ensures Namespace, ServiceAccount, RoleBinding, and
|
||||||
|
// ResourceQuota resources for the tenant binding.
|
||||||
|
func (c *TenantClient) EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||||
|
binding = binding.WithDefaults()
|
||||||
|
if err := binding.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clientset, _, err := c.clientsetForCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureNamespace(ctx, clientset, binding); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureServiceAccount(ctx, clientset, binding); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureRoleBinding(ctx, clientset, binding); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := c.ensureResourceQuota(ctx, clientset, binding); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueKubeconfig returns a short-lived kubeconfig backed by a Kubernetes
|
||||||
|
// TokenRequest. The token exists only in the returned value and is never stored.
|
||||||
|
func (c *TenantClient) IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||||
|
binding = binding.WithDefaults()
|
||||||
|
if err := binding.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clientset, restConfig, err := c.clientsetForCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cappedTTL := entity.TenantTokenTTL(ttl)
|
||||||
|
expirationSeconds := int64(cappedTTL.Seconds())
|
||||||
|
tokenRequest, err := clientset.CoreV1().
|
||||||
|
ServiceAccounts(binding.Namespace).
|
||||||
|
CreateToken(ctx, binding.ServiceAccountName, &authenticationv1.TokenRequest{
|
||||||
|
Spec: authenticationv1.TokenRequestSpec{
|
||||||
|
ExpirationSeconds: &expirationSeconds,
|
||||||
|
},
|
||||||
|
}, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to request tenant service account token: %w", err)
|
||||||
|
}
|
||||||
|
if tokenRequest.Status.Token == "" {
|
||||||
|
return nil, entity.ErrInvalidTenantKubeconfigToken
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := tokenRequest.Status.ExpirationTimestamp.Time
|
||||||
|
if expiresAt.IsZero() {
|
||||||
|
expiresAt = time.Now().Add(cappedTTL)
|
||||||
|
}
|
||||||
|
kubeconfig, err := buildTenantKubeconfig(cluster, restConfig, binding, tokenRequest.Status.Token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &entity.TenantKubeconfig{
|
||||||
|
Kubeconfig: kubeconfig,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuspendTenant revokes tenant API access by deleting only the RoleBinding.
|
||||||
|
func (c *TenantClient) SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||||
|
binding = binding.WithDefaults()
|
||||||
|
if err := binding.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clientset, _, err := c.clientsetForCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = clientset.RbacV1().
|
||||||
|
RoleBindings(binding.Namespace).
|
||||||
|
Delete(ctx, binding.RoleBindingName, metav1.DeleteOptions{})
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete tenant role binding: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TenantClient) clientsetForCluster(cluster *entity.Cluster) (kubernetes.Interface, *rest.Config, error) {
|
||||||
|
if c.clientset != nil {
|
||||||
|
config := &rest.Config{Host: "https://kubernetes.default.svc"}
|
||||||
|
if cluster != nil {
|
||||||
|
clusterConfig, err := restConfigFromCluster(cluster)
|
||||||
|
if err == nil {
|
||||||
|
config = clusterConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.clientset, config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := restConfigFromCluster(cluster)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
clientset, err := kubernetes.NewForConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to create tenant kubernetes client: %w", err)
|
||||||
|
}
|
||||||
|
return clientset, config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restConfigFromCluster(cluster *entity.Cluster) (*rest.Config, error) {
|
||||||
|
if cluster == nil {
|
||||||
|
return nil, entity.ErrInvalidClusterHost
|
||||||
|
}
|
||||||
|
if looksLikeKubeconfig(cluster.CAData) {
|
||||||
|
config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.CAData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse tenant kubeconfig: %w", err)
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cluster.Host) == "" {
|
||||||
|
return nil, entity.ErrInvalidClusterHost
|
||||||
|
}
|
||||||
|
return &rest.Config{
|
||||||
|
Host: cluster.Host,
|
||||||
|
TLSClientConfig: rest.TLSClientConfig{
|
||||||
|
CAData: decodePossiblyBase64(cluster.CAData),
|
||||||
|
CertData: decodePossiblyBase64(cluster.CertData),
|
||||||
|
KeyData: decodePossiblyBase64(cluster.KeyData),
|
||||||
|
},
|
||||||
|
BearerToken: cluster.Token,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TenantClient) ensureNamespace(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||||
|
namespace := &corev1.Namespace{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: binding.Namespace,
|
||||||
|
Labels: copyStringMap(binding.Labels),
|
||||||
|
Annotations: copyStringMap(binding.Annotations),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := clientset.CoreV1().Namespaces().Create(ctx, namespace, metav1.CreateOptions{})
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
current, getErr := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{})
|
||||||
|
if getErr != nil {
|
||||||
|
return fmt.Errorf("failed to get tenant namespace: %w", getErr)
|
||||||
|
}
|
||||||
|
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||||
|
if _, updateErr := clientset.CoreV1().Namespaces().Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||||
|
return fmt.Errorf("failed to update tenant namespace: %w", updateErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create tenant namespace: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TenantClient) ensureServiceAccount(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||||
|
serviceAccount := &corev1.ServiceAccount{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: binding.ServiceAccountName,
|
||||||
|
Namespace: binding.Namespace,
|
||||||
|
Labels: copyStringMap(binding.Labels),
|
||||||
|
Annotations: copyStringMap(binding.Annotations),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Create(ctx, serviceAccount, metav1.CreateOptions{})
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
current, getErr := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{})
|
||||||
|
if getErr != nil {
|
||||||
|
return fmt.Errorf("failed to get tenant service account: %w", getErr)
|
||||||
|
}
|
||||||
|
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||||
|
if _, updateErr := clientset.CoreV1().ServiceAccounts(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||||
|
return fmt.Errorf("failed to update tenant service account: %w", updateErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create tenant service account: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TenantClient) ensureRoleBinding(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||||
|
roleBinding := desiredRoleBinding(binding)
|
||||||
|
_, err := clientset.RbacV1().RoleBindings(binding.Namespace).Create(ctx, roleBinding, metav1.CreateOptions{})
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
current, getErr := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
||||||
|
if getErr != nil {
|
||||||
|
return fmt.Errorf("failed to get tenant role binding: %w", getErr)
|
||||||
|
}
|
||||||
|
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||||
|
current.Subjects = roleBinding.Subjects
|
||||||
|
current.RoleRef = roleBinding.RoleRef
|
||||||
|
if _, updateErr := clientset.RbacV1().RoleBindings(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||||
|
return fmt.Errorf("failed to update tenant role binding: %w", updateErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create tenant role binding: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TenantClient) ensureResourceQuota(ctx context.Context, clientset kubernetes.Interface, binding entity.TenantBinding) error {
|
||||||
|
resourceQuota := &corev1.ResourceQuota{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: binding.ResourceQuotaName,
|
||||||
|
Namespace: binding.Namespace,
|
||||||
|
Labels: copyStringMap(binding.Labels),
|
||||||
|
Annotations: copyStringMap(binding.Annotations),
|
||||||
|
},
|
||||||
|
Spec: corev1.ResourceQuotaSpec{
|
||||||
|
Hard: binding.ResourceQuotaHard.DeepCopy(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Create(ctx, resourceQuota, metav1.CreateOptions{})
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
current, getErr := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
||||||
|
if getErr != nil {
|
||||||
|
return fmt.Errorf("failed to get tenant resource quota: %w", getErr)
|
||||||
|
}
|
||||||
|
mergeObjectMetadata(¤t.ObjectMeta, binding.Labels, binding.Annotations)
|
||||||
|
current.Spec.Hard = binding.ResourceQuotaHard.DeepCopy()
|
||||||
|
if _, updateErr := clientset.CoreV1().ResourceQuotas(binding.Namespace).Update(ctx, current, metav1.UpdateOptions{}); updateErr != nil {
|
||||||
|
return fmt.Errorf("failed to update tenant resource quota: %w", updateErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create tenant resource quota: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func desiredRoleBinding(binding entity.TenantBinding) *rbacv1.RoleBinding {
|
||||||
|
return &rbacv1.RoleBinding{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: binding.RoleBindingName,
|
||||||
|
Namespace: binding.Namespace,
|
||||||
|
Labels: copyStringMap(binding.Labels),
|
||||||
|
Annotations: copyStringMap(binding.Annotations),
|
||||||
|
},
|
||||||
|
Subjects: []rbacv1.Subject{{
|
||||||
|
Kind: rbacv1.ServiceAccountKind,
|
||||||
|
Name: binding.ServiceAccountName,
|
||||||
|
Namespace: binding.Namespace,
|
||||||
|
}},
|
||||||
|
RoleRef: rbacv1.RoleRef{
|
||||||
|
APIGroup: rbacv1.GroupName,
|
||||||
|
Kind: "ClusterRole",
|
||||||
|
Name: binding.ClusterRoleName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTenantKubeconfig(cluster *entity.Cluster, restConfig *rest.Config, binding entity.TenantBinding, token string) (string, error) {
|
||||||
|
host := ""
|
||||||
|
var caData []byte
|
||||||
|
if restConfig != nil {
|
||||||
|
host = restConfig.Host
|
||||||
|
caData = append([]byte{}, restConfig.CAData...)
|
||||||
|
}
|
||||||
|
if host == "" && cluster != nil {
|
||||||
|
host = cluster.Host
|
||||||
|
}
|
||||||
|
if len(caData) == 0 && cluster != nil {
|
||||||
|
caData = decodePossiblyBase64(cluster.CAData)
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return "", entity.ErrInvalidClusterHost
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterName := "tenant-cluster"
|
||||||
|
if cluster != nil && cluster.Name != "" {
|
||||||
|
clusterName = cluster.Name
|
||||||
|
}
|
||||||
|
userName := binding.ServiceAccountName
|
||||||
|
contextName := fmt.Sprintf("%s/%s", clusterName, binding.Namespace)
|
||||||
|
config := clientcmdapi.NewConfig()
|
||||||
|
config.Clusters[clusterName] = &clientcmdapi.Cluster{
|
||||||
|
Server: host,
|
||||||
|
CertificateAuthorityData: caData,
|
||||||
|
}
|
||||||
|
config.AuthInfos[userName] = &clientcmdapi.AuthInfo{
|
||||||
|
Token: token,
|
||||||
|
}
|
||||||
|
config.Contexts[contextName] = &clientcmdapi.Context{
|
||||||
|
Cluster: clusterName,
|
||||||
|
AuthInfo: userName,
|
||||||
|
Namespace: binding.Namespace,
|
||||||
|
}
|
||||||
|
config.CurrentContext = contextName
|
||||||
|
|
||||||
|
bytes, err := clientcmd.Write(*config)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to build tenant kubeconfig: %w", err)
|
||||||
|
}
|
||||||
|
return string(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeObjectMetadata(meta *metav1.ObjectMeta, labels, annotations map[string]string) {
|
||||||
|
if len(labels) > 0 && meta.Labels == nil {
|
||||||
|
meta.Labels = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, value := range labels {
|
||||||
|
meta.Labels[key] = value
|
||||||
|
}
|
||||||
|
if len(annotations) > 0 && meta.Annotations == nil {
|
||||||
|
meta.Annotations = map[string]string{}
|
||||||
|
}
|
||||||
|
for key, value := range annotations {
|
||||||
|
meta.Annotations[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyStringMap(values map[string]string) map[string]string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copied := make(map[string]string, len(values))
|
||||||
|
for key, value := range values {
|
||||||
|
copied[key] = value
|
||||||
|
}
|
||||||
|
return copied
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePossiblyBase64(value string) []byte {
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||||
|
if err == nil {
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
return []byte(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeKubeconfig(value string) bool {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
return strings.HasPrefix(trimmed, "apiVersion:") || strings.HasPrefix(trimmed, "kind: Config")
|
||||||
|
}
|
||||||
172
backend/internal/adapter/output/k8s/tenant_client_test.go
Normal file
172
backend/internal/adapter/output/k8s/tenant_client_test.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
authenticationv1 "k8s.io/api/authentication/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
rbacv1 "k8s.io/api/rbac/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
|
k8stesting "k8s.io/client-go/testing"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTenantClientEnsureTenantCreatesResources(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
clientset := fake.NewSimpleClientset()
|
||||||
|
client := NewTenantClientForClientset(clientset)
|
||||||
|
binding := tenantBinding()
|
||||||
|
|
||||||
|
if err := client.EnsureTenant(ctx, nil, binding); err != nil {
|
||||||
|
t.Fatalf("EnsureTenant returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := clientset.CoreV1().Namespaces().Get(ctx, binding.Namespace, metav1.GetOptions{}); err != nil {
|
||||||
|
t.Fatalf("expected namespace: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil {
|
||||||
|
t.Fatalf("expected service account: %v", err)
|
||||||
|
}
|
||||||
|
roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected role binding: %v", err)
|
||||||
|
}
|
||||||
|
if roleBinding.RoleRef.Kind != "ClusterRole" || roleBinding.RoleRef.Name != binding.ClusterRoleName {
|
||||||
|
t.Fatalf("unexpected role ref: %#v", roleBinding.RoleRef)
|
||||||
|
}
|
||||||
|
if len(roleBinding.Subjects) != 1 || roleBinding.Subjects[0].Name != binding.ServiceAccountName {
|
||||||
|
t.Fatalf("unexpected role binding subjects: %#v", roleBinding.Subjects)
|
||||||
|
}
|
||||||
|
quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected resource quota: %v", err)
|
||||||
|
}
|
||||||
|
if quota.Spec.Hard.Cpu().String() != "2" {
|
||||||
|
t.Fatalf("expected cpu quota 2, got %s", quota.Spec.Hard.Cpu().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantClientEnsureTenantUpdatesExistingResources(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
binding := tenantBinding()
|
||||||
|
clientset := fake.NewSimpleClientset(
|
||||||
|
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace}},
|
||||||
|
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}},
|
||||||
|
&rbacv1.RoleBinding{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: binding.RoleBindingName, Namespace: binding.Namespace},
|
||||||
|
RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: "view"},
|
||||||
|
},
|
||||||
|
&corev1.ResourceQuota{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: binding.ResourceQuotaName, Namespace: binding.Namespace},
|
||||||
|
Spec: corev1.ResourceQuotaSpec{Hard: corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: resource.MustParse("1"),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
client := NewTenantClientForClientset(clientset)
|
||||||
|
|
||||||
|
if err := client.EnsureTenant(ctx, nil, binding); err != nil {
|
||||||
|
t.Fatalf("EnsureTenant returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
roleBinding, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected updated role binding: %v", err)
|
||||||
|
}
|
||||||
|
if roleBinding.RoleRef.Name != binding.ClusterRoleName {
|
||||||
|
t.Fatalf("expected role ref %q, got %q", binding.ClusterRoleName, roleBinding.RoleRef.Name)
|
||||||
|
}
|
||||||
|
if roleBinding.Labels["ocdp.io/tenant"] != binding.Namespace {
|
||||||
|
t.Fatalf("expected tenant label on updated role binding, got %#v", roleBinding.Labels)
|
||||||
|
}
|
||||||
|
quota, err := clientset.CoreV1().ResourceQuotas(binding.Namespace).Get(ctx, binding.ResourceQuotaName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected updated quota: %v", err)
|
||||||
|
}
|
||||||
|
if quota.Spec.Hard.Cpu().String() != "2" {
|
||||||
|
t.Fatalf("expected updated cpu quota 2, got %s", quota.Spec.Hard.Cpu().String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantClientSuspendTenantDeletesOnlyRoleBinding(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
binding := tenantBinding()
|
||||||
|
clientset := fake.NewSimpleClientset(
|
||||||
|
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: binding.Namespace}},
|
||||||
|
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace}},
|
||||||
|
desiredRoleBinding(binding),
|
||||||
|
)
|
||||||
|
client := NewTenantClientForClientset(clientset)
|
||||||
|
|
||||||
|
if err := client.SuspendTenant(ctx, nil, binding); err != nil {
|
||||||
|
t.Fatalf("SuspendTenant returned error: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := clientset.RbacV1().RoleBindings(binding.Namespace).Get(ctx, binding.RoleBindingName, metav1.GetOptions{}); !apierrors.IsNotFound(err) {
|
||||||
|
t.Fatalf("expected deleted role binding, got err %v", err)
|
||||||
|
}
|
||||||
|
if _, err := clientset.CoreV1().ServiceAccounts(binding.Namespace).Get(ctx, binding.ServiceAccountName, metav1.GetOptions{}); err != nil {
|
||||||
|
t.Fatalf("service account should remain: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantClientIssueKubeconfigCapsTokenTTL(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
binding := tenantBinding()
|
||||||
|
clientset := fake.NewSimpleClientset(&corev1.ServiceAccount{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: binding.ServiceAccountName, Namespace: binding.Namespace},
|
||||||
|
})
|
||||||
|
var requestedExpirationSeconds int64
|
||||||
|
expiresAt := time.Now().Add(entity.MaxTenantKubeconfigTTL).UTC()
|
||||||
|
clientset.Fake.PrependReactor("create", "serviceaccounts", func(action k8stesting.Action) (bool, runtime.Object, error) {
|
||||||
|
if action.GetSubresource() != "token" {
|
||||||
|
return false, nil, nil
|
||||||
|
}
|
||||||
|
createAction := action.(k8stesting.CreateAction)
|
||||||
|
tokenRequest := createAction.GetObject().(*authenticationv1.TokenRequest)
|
||||||
|
if tokenRequest.Spec.ExpirationSeconds != nil {
|
||||||
|
requestedExpirationSeconds = *tokenRequest.Spec.ExpirationSeconds
|
||||||
|
}
|
||||||
|
return true, &authenticationv1.TokenRequest{
|
||||||
|
Status: authenticationv1.TokenRequestStatus{
|
||||||
|
Token: "short-lived-token",
|
||||||
|
ExpirationTimestamp: metav1.NewTime(expiresAt),
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
client := NewTenantClientForClientset(clientset)
|
||||||
|
|
||||||
|
kubeconfig, err := client.IssueKubeconfig(ctx, &entity.Cluster{Name: "test", Host: "https://example.invalid"}, binding, 24*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IssueKubeconfig returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestedExpirationSeconds != int64(entity.MaxTenantKubeconfigTTL.Seconds()) {
|
||||||
|
t.Fatalf("expected capped ttl %d, got %d", int64(entity.MaxTenantKubeconfigTTL.Seconds()), requestedExpirationSeconds)
|
||||||
|
}
|
||||||
|
if !kubeconfig.ExpiresAt.Equal(expiresAt) {
|
||||||
|
t.Fatalf("expected expiration %s, got %s", expiresAt, kubeconfig.ExpiresAt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(kubeconfig.Kubeconfig, "short-lived-token") {
|
||||||
|
t.Fatal("expected kubeconfig to contain issued token")
|
||||||
|
}
|
||||||
|
if !strings.Contains(kubeconfig.Kubeconfig, "namespace: tenant-a") {
|
||||||
|
t.Fatalf("expected kubeconfig namespace, got:\n%s", kubeconfig.Kubeconfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tenantBinding() entity.TenantBinding {
|
||||||
|
binding := entity.NewTenantBinding("tenant-a")
|
||||||
|
binding.ResourceQuotaHard = corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: resource.MustParse("2"),
|
||||||
|
corev1.ResourceMemory: resource.MustParse("4Gi"),
|
||||||
|
}
|
||||||
|
return binding
|
||||||
|
}
|
||||||
36
backend/internal/adapter/output/k8s/tenant_mock.go
Normal file
36
backend/internal/adapter/output/k8s/tenant_mock.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package k8s
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MockTenantClient struct{}
|
||||||
|
|
||||||
|
func NewMockTenantClient() repository.TenantKubeClient {
|
||||||
|
return &MockTenantClient{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockTenantClient) EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||||
|
return binding.Validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockTenantClient) IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||||
|
if err := binding.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
expiresAt := time.Now().Add(entity.TenantTokenTTL(ttl))
|
||||||
|
return &entity.TenantKubeconfig{
|
||||||
|
Kubeconfig: fmt.Sprintf("apiVersion: v1\nkind: Config\nclusters:\n- name: %s\n cluster:\n server: %s\ncontexts:\n- name: %s\n context:\n cluster: %s\n namespace: %s\n user: %s\ncurrent-context: %s\nusers:\n- name: %s\n user:\n token: mock-ephemeral-token\n",
|
||||||
|
cluster.Name, cluster.Host, binding.Namespace, cluster.Name, binding.Namespace, binding.ServiceAccountName, binding.Namespace, binding.ServiceAccountName),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MockTenantClient) SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error {
|
||||||
|
return binding.Validate()
|
||||||
|
}
|
||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
)
|
)
|
||||||
@ -13,7 +13,7 @@ import (
|
|||||||
// OCIClientMock OCI Registry 客户端 Mock 实现
|
// OCIClientMock OCI Registry 客户端 Mock 实现
|
||||||
type OCIClientMock struct {
|
type OCIClientMock struct {
|
||||||
// Mock 数据存储
|
// Mock 数据存储
|
||||||
repositories map[string][]string // registryID -> []repositoryName
|
repositories map[string][]string // registryID -> []repositoryName
|
||||||
artifacts map[string]map[string][]*entity.Artifact // registryID -> repository -> []artifact
|
artifacts map[string]map[string][]*entity.Artifact // registryID -> repository -> []artifact
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,10 +23,10 @@ func NewOCIClientMock() repository.OCIClient {
|
|||||||
repositories: make(map[string][]string),
|
repositories: make(map[string][]string),
|
||||||
artifacts: make(map[string]map[string][]*entity.Artifact),
|
artifacts: make(map[string]map[string][]*entity.Artifact),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化一些测试数据
|
// 初始化一些测试数据
|
||||||
mock.initMockData()
|
mock.initMockData()
|
||||||
|
|
||||||
return mock
|
return mock
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,18 +38,18 @@ func (c *OCIClientMock) initMockData() {
|
|||||||
// initArtifactsForRegistry initializes mock artifacts for a given registry ID
|
// initArtifactsForRegistry initializes mock artifacts for a given registry ID
|
||||||
func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||||
c.artifacts[registryID] = make(map[string][]*entity.Artifact)
|
c.artifacts[registryID] = make(map[string][]*entity.Artifact)
|
||||||
|
|
||||||
// vllm-serve artifacts (OCI 格式的 Helm Chart)
|
// vllm-serve artifacts (OCI 格式的 Helm Chart)
|
||||||
c.artifacts[registryID]["charts/vllm-serve"] = []*entity.Artifact{
|
c.artifacts[registryID]["charts/vllm-serve"] = []*entity.Artifact{
|
||||||
{
|
{
|
||||||
RegistryID: registryID,
|
RegistryID: registryID,
|
||||||
Repository: "charts/vllm-serve",
|
Repository: "charts/vllm-serve",
|
||||||
Tag: "0.1.0",
|
Tag: "0.1.0",
|
||||||
Digest: "sha256:abc123def456",
|
Digest: "sha256:abc123def456",
|
||||||
Type: entity.ArtifactTypeChart,
|
Type: entity.ArtifactTypeChart,
|
||||||
Size: 12345678,
|
Size: 12345678,
|
||||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"org.opencontainers.image.title": "vllm-serve",
|
"org.opencontainers.image.title": "vllm-serve",
|
||||||
"org.opencontainers.image.version": "0.1.0",
|
"org.opencontainers.image.version": "0.1.0",
|
||||||
@ -57,14 +57,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
|||||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
RegistryID: registryID,
|
RegistryID: registryID,
|
||||||
Repository: "charts/vllm-serve",
|
Repository: "charts/vllm-serve",
|
||||||
Tag: "0.2.0",
|
Tag: "0.2.0",
|
||||||
Digest: "sha256:xyz789uvw012",
|
Digest: "sha256:xyz789uvw012",
|
||||||
Type: entity.ArtifactTypeChart,
|
Type: entity.ArtifactTypeChart,
|
||||||
Size: 13456789,
|
Size: 13456789,
|
||||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"org.opencontainers.image.title": "vllm-serve",
|
"org.opencontainers.image.title": "vllm-serve",
|
||||||
"org.opencontainers.image.version": "0.2.0",
|
"org.opencontainers.image.version": "0.2.0",
|
||||||
@ -72,36 +72,36 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// nginx artifacts (OCI 格式的 Helm Chart)
|
// nginx artifacts (OCI 格式的 Helm Chart)
|
||||||
c.artifacts[registryID]["charts/nginx"] = []*entity.Artifact{
|
c.artifacts[registryID]["charts/nginx"] = []*entity.Artifact{
|
||||||
{
|
{
|
||||||
RegistryID: registryID,
|
RegistryID: registryID,
|
||||||
Repository: "charts/nginx",
|
Repository: "charts/nginx",
|
||||||
Tag: "1.0.0",
|
Tag: "1.0.0",
|
||||||
Digest: "sha256:nginx123456",
|
Digest: "sha256:nginx123456",
|
||||||
Type: entity.ArtifactTypeChart,
|
Type: entity.ArtifactTypeChart,
|
||||||
Size: 5678901,
|
Size: 5678901,
|
||||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"org.opencontainers.image.title": "nginx",
|
"org.opencontainers.image.title": "nginx",
|
||||||
},
|
},
|
||||||
CreatedAt: time.Now().Add(-48 * time.Hour),
|
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// redis artifacts (OCI 格式的 Helm Chart)
|
// redis artifacts (OCI 格式的 Helm Chart)
|
||||||
c.artifacts[registryID]["charts/redis"] = []*entity.Artifact{
|
c.artifacts[registryID]["charts/redis"] = []*entity.Artifact{
|
||||||
{
|
{
|
||||||
RegistryID: registryID,
|
RegistryID: registryID,
|
||||||
Repository: "charts/redis",
|
Repository: "charts/redis",
|
||||||
Tag: "6.2.0",
|
Tag: "6.2.0",
|
||||||
Digest: "sha256:redis789abc",
|
Digest: "sha256:redis789abc",
|
||||||
Type: entity.ArtifactTypeChart,
|
Type: entity.ArtifactTypeChart,
|
||||||
Size: 8901234,
|
Size: 8901234,
|
||||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"org.opencontainers.image.title": "redis",
|
"org.opencontainers.image.title": "redis",
|
||||||
"org.opencontainers.image.version": "6.2.0",
|
"org.opencontainers.image.version": "6.2.0",
|
||||||
@ -109,18 +109,18 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
|||||||
CreatedAt: time.Now().Add(-72 * time.Hour),
|
CreatedAt: time.Now().Add(-72 * time.Hour),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// alpine artifacts (Docker Image)
|
// alpine artifacts (Docker Image)
|
||||||
c.artifacts[registryID]["library/alpine"] = []*entity.Artifact{
|
c.artifacts[registryID]["library/alpine"] = []*entity.Artifact{
|
||||||
{
|
{
|
||||||
RegistryID: registryID,
|
RegistryID: registryID,
|
||||||
Repository: "library/alpine",
|
Repository: "library/alpine",
|
||||||
Tag: "3.18",
|
Tag: "3.18",
|
||||||
Digest: "sha256:alpine123",
|
Digest: "sha256:alpine123",
|
||||||
Type: entity.ArtifactTypeImage,
|
Type: entity.ArtifactTypeImage,
|
||||||
Size: 2345678,
|
Size: 2345678,
|
||||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"org.opencontainers.image.title": "alpine",
|
"org.opencontainers.image.title": "alpine",
|
||||||
"org.opencontainers.image.version": "3.18",
|
"org.opencontainers.image.version": "3.18",
|
||||||
@ -128,14 +128,14 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
|||||||
CreatedAt: time.Now().Add(-96 * time.Hour),
|
CreatedAt: time.Now().Add(-96 * time.Hour),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
RegistryID: registryID,
|
RegistryID: registryID,
|
||||||
Repository: "library/alpine",
|
Repository: "library/alpine",
|
||||||
Tag: "latest",
|
Tag: "latest",
|
||||||
Digest: "sha256:alpine456",
|
Digest: "sha256:alpine456",
|
||||||
Type: entity.ArtifactTypeImage,
|
Type: entity.ArtifactTypeImage,
|
||||||
Size: 2456789,
|
Size: 2456789,
|
||||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||||
Annotations: map[string]string{
|
Annotations: map[string]string{
|
||||||
"org.opencontainers.image.title": "alpine",
|
"org.opencontainers.image.title": "alpine",
|
||||||
},
|
},
|
||||||
@ -144,7 +144,7 @@ func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) {
|
||||||
// Check if we have cached data for this registry
|
// Check if we have cached data for this registry
|
||||||
repos, exists := c.repositories[registry.ID]
|
repos, exists := c.repositories[registry.ID]
|
||||||
if !exists {
|
if !exists {
|
||||||
@ -156,10 +156,20 @@ func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.R
|
|||||||
"library/alpine",
|
"library/alpine",
|
||||||
}
|
}
|
||||||
c.repositories[registry.ID] = repos
|
c.repositories[registry.ID] = repos
|
||||||
|
|
||||||
// Also initialize artifacts for this registry
|
// Also initialize artifacts for this registry
|
||||||
c.initArtifactsForRegistry(registry.ID)
|
c.initArtifactsForRegistry(registry.ID)
|
||||||
}
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(artifactType), "chart") {
|
||||||
|
chartRepos := make([]string, 0)
|
||||||
|
for _, repo := range repos {
|
||||||
|
artifacts, _ := c.ListArtifacts(ctx, registry, repo, "chart")
|
||||||
|
if len(artifacts) > 0 {
|
||||||
|
chartRepos = append(chartRepos, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chartRepos, nil
|
||||||
|
}
|
||||||
return repos, nil
|
return repos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,20 +180,20 @@ func (c *OCIClientMock) ListArtifacts(ctx context.Context, registry *entity.Regi
|
|||||||
c.initArtifactsForRegistry(registry.ID)
|
c.initArtifactsForRegistry(registry.ID)
|
||||||
regArtifacts = c.artifacts[registry.ID]
|
regArtifacts = c.artifacts[registry.ID]
|
||||||
}
|
}
|
||||||
|
|
||||||
artifacts, exists := regArtifacts[repository]
|
artifacts, exists := regArtifacts[repository]
|
||||||
if !exists {
|
if !exists {
|
||||||
return []*entity.Artifact{}, nil
|
return []*entity.Artifact{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用 mediaType 过滤
|
// 应用 mediaType 过滤
|
||||||
if mediaTypeFilter == "" || mediaTypeFilter == "all" {
|
if mediaTypeFilter == "" || mediaTypeFilter == "all" {
|
||||||
return artifacts, nil
|
return artifacts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]*entity.Artifact, 0)
|
filtered := make([]*entity.Artifact, 0)
|
||||||
filter := strings.ToLower(strings.TrimSpace(mediaTypeFilter))
|
filter := strings.ToLower(strings.TrimSpace(mediaTypeFilter))
|
||||||
|
|
||||||
for _, artifact := range artifacts {
|
for _, artifact := range artifacts {
|
||||||
switch filter {
|
switch filter {
|
||||||
case "chart":
|
case "chart":
|
||||||
@ -200,7 +210,7 @@ func (c *OCIClientMock) ListArtifacts(ctx context.Context, registry *entity.Regi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered, nil
|
return filtered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -211,19 +221,19 @@ func (c *OCIClientMock) GetArtifact(ctx context.Context, registry *entity.Regist
|
|||||||
c.initArtifactsForRegistry(registry.ID)
|
c.initArtifactsForRegistry(registry.ID)
|
||||||
regArtifacts = c.artifacts[registry.ID]
|
regArtifacts = c.artifacts[registry.ID]
|
||||||
}
|
}
|
||||||
|
|
||||||
artifacts, exists := regArtifacts[repository]
|
artifacts, exists := regArtifacts[repository]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, entity.ErrArtifactNotFound
|
return nil, entity.ErrArtifactNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据 tag 或 digest 查找
|
// 根据 tag 或 digest 查找
|
||||||
for _, artifact := range artifacts {
|
for _, artifact := range artifacts {
|
||||||
if artifact.Tag == reference || artifact.Digest == reference {
|
if artifact.Tag == reference || artifact.Digest == reference {
|
||||||
return artifact, nil
|
return artifact, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, entity.ErrArtifactNotFound
|
return nil, entity.ErrArtifactNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,11 +242,11 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !artifact.IsChart() {
|
if !artifact.IsChart() {
|
||||||
return "", fmt.Errorf("not a helm chart")
|
return "", fmt.Errorf("not a helm chart")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回 Mock values schema
|
// 返回 Mock values schema
|
||||||
mockSchema := `{
|
mockSchema := `{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
@ -262,12 +272,23 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
|
|||||||
return mockSchema, nil
|
return mockSchema, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OCIClientMock) GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
|
||||||
|
artifact, err := c.GetArtifact(ctx, registry, repository, reference)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !artifact.IsChart() {
|
||||||
|
return "", fmt.Errorf("not a helm chart")
|
||||||
|
}
|
||||||
|
return "replicaCount: 1\nimage:\n repository: nginx\n tag: latest\nservice:\n type: ClusterIP\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
|
func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
|
||||||
_, err := c.GetArtifact(ctx, registry, repository, reference)
|
_, err := c.GetArtifact(ctx, registry, repository, reference)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock 实现,不实际下载
|
// Mock 实现,不实际下载
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -281,4 +302,3 @@ func (c *OCIClientMock) CheckHealth(ctx context.Context, registry *entity.Regist
|
|||||||
// Mock 实现,总是返回健康
|
// Mock 实现,总是返回健康
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
@ -25,6 +29,30 @@ type OCIClient struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type harborProject struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type harborRepository struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ArtifactCount int `json:"artifact_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type harborTag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
PushTime string `json:"push_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type harborArtifact struct {
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
ArtifactType string `json:"artifact_type"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
PushTime string `json:"push_time"`
|
||||||
|
Tags []harborTag `json:"tags"`
|
||||||
|
Annotations map[string]string `json:"annotations"`
|
||||||
|
}
|
||||||
|
|
||||||
// NewOCIClient 创建真实的 OCI 客户端
|
// NewOCIClient 创建真实的 OCI 客户端
|
||||||
func NewOCIClient() repository.OCIClient {
|
func NewOCIClient() repository.OCIClient {
|
||||||
return &OCIClient{
|
return &OCIClient{
|
||||||
@ -60,8 +88,34 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
|
|||||||
return registry, nil
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRepositories 列出 Registry 中的所有 repositories
|
// ListRepositories 列出 Registry 中的 repositories.
|
||||||
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
// Harbor registry 优先使用 Harbor v2.0 API,避免 robot 账号依赖 /v2/_catalog 全局权限。
|
||||||
|
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) {
|
||||||
|
repositories, harborErr := c.listHarborRepositories(ctx, registry, artifactType)
|
||||||
|
if harborErr == nil {
|
||||||
|
return repositories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories, catalogErr := c.listOCIRepositories(ctx, registry)
|
||||||
|
if catalogErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list repositories via Harbor API: %v; OCI catalog fallback also failed: %w", harborErr, catalogErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(strings.TrimSpace(artifactType), "chart") {
|
||||||
|
chartRepos := make([]string, 0)
|
||||||
|
for _, repo := range repositories {
|
||||||
|
artifacts, err := c.ListArtifacts(ctx, registry, repo, "chart")
|
||||||
|
if err == nil && len(artifacts) > 0 {
|
||||||
|
chartRepos = append(chartRepos, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chartRepos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return repositories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) listOCIRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
||||||
reg, err := c.getRegistry(registry)
|
reg, err := c.getRegistry(registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -81,9 +135,278 @@ func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Regis
|
|||||||
return repositories, nil
|
return repositories, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) listHarborRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error) {
|
||||||
|
projects, err := c.harborListProjects(ctx, registry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repositorySet := make(map[string]struct{})
|
||||||
|
chartOnly := strings.EqualFold(strings.TrimSpace(artifactType), "chart") || strings.TrimSpace(artifactType) == ""
|
||||||
|
|
||||||
|
for _, project := range projects {
|
||||||
|
projectName := strings.TrimSpace(project.Name)
|
||||||
|
if projectName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories, err := c.harborListProjectRepositories(ctx, registry, projectName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, harborRepo := range repositories {
|
||||||
|
repoName := normalizeHarborRepositoryName(projectName, harborRepo.Name)
|
||||||
|
if repoName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if chartOnly {
|
||||||
|
artifacts, err := c.listHarborArtifacts(ctx, registry, repoName, "chart")
|
||||||
|
if err != nil || len(artifacts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositorySet[repoName] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories := make([]string, 0, len(repositorySet))
|
||||||
|
for repo := range repositorySet {
|
||||||
|
repositories = append(repositories, repo)
|
||||||
|
}
|
||||||
|
sort.Strings(repositories)
|
||||||
|
return repositories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) harborListProjects(ctx context.Context, registry *entity.Registry) ([]harborProject, error) {
|
||||||
|
var projects []harborProject
|
||||||
|
if err := c.harborGetPaged(ctx, registry, "/api/v2.0/projects", url.Values{"member": []string{"true"}}, &projects); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return projects, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) harborListProjectRepositories(ctx context.Context, registry *entity.Registry, projectName string) ([]harborRepository, error) {
|
||||||
|
var repositories []harborRepository
|
||||||
|
path := "/api/v2.0/projects/" + url.PathEscape(projectName) + "/repositories"
|
||||||
|
if err := c.harborGetPaged(ctx, registry, path, nil, &repositories); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return repositories, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) listHarborArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||||
|
projectName, repoName, ok := splitHarborRepository(repository)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("repository %q is not a Harbor project repository path", repository)
|
||||||
|
}
|
||||||
|
|
||||||
|
var harborArtifacts []harborArtifact
|
||||||
|
path := "/api/v2.0/projects/" + url.PathEscape(projectName) + "/repositories/" + url.PathEscape(repoName) + "/artifacts"
|
||||||
|
query := url.Values{
|
||||||
|
"with_tag": []string{"true"},
|
||||||
|
"with_label": []string{"false"},
|
||||||
|
}
|
||||||
|
if err := c.harborGetPaged(ctx, registry, path, query, &harborArtifacts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts := make([]*entity.Artifact, 0)
|
||||||
|
for _, harborArtifact := range harborArtifacts {
|
||||||
|
tags := harborArtifact.Tags
|
||||||
|
if len(tags) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
if strings.TrimSpace(tag.Name) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
artifact := &entity.Artifact{
|
||||||
|
Repository: repository,
|
||||||
|
Tag: tag.Name,
|
||||||
|
Digest: harborArtifact.Digest,
|
||||||
|
MediaType: harborArtifact.MediaType,
|
||||||
|
ConfigType: harborArtifact.ArtifactType,
|
||||||
|
Size: harborArtifact.Size,
|
||||||
|
Annotations: harborArtifact.Annotations,
|
||||||
|
CreatedAt: parseHarborTime(firstNonEmpty(tag.PushTime, harborArtifact.PushTime)),
|
||||||
|
}
|
||||||
|
if artifact.Annotations == nil {
|
||||||
|
artifact.Annotations = make(map[string]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact.DetermineType()
|
||||||
|
if isHarborChartArtifact(harborArtifact) {
|
||||||
|
artifact.Type = entity.ArtifactTypeChart
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.shouldIncludeArtifact(artifact, mediaTypeFilter) {
|
||||||
|
artifacts = append(artifacts, artifact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) harborGetPaged(ctx context.Context, registry *entity.Registry, path string, query url.Values, target interface{}) error {
|
||||||
|
const pageSize = 100
|
||||||
|
|
||||||
|
accumulated := make([]json.RawMessage, 0)
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
pageQuery := cloneValues(query)
|
||||||
|
pageQuery.Set("page", fmt.Sprintf("%d", page))
|
||||||
|
pageQuery.Set("page_size", fmt.Sprintf("%d", pageSize))
|
||||||
|
|
||||||
|
body, total, err := c.harborGet(ctx, registry, path, pageQuery)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageItems []json.RawMessage
|
||||||
|
if err := json.Unmarshal(body, &pageItems); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode Harbor response for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
accumulated = append(accumulated, pageItems...)
|
||||||
|
|
||||||
|
if len(pageItems) < pageSize || (total >= 0 && len(accumulated) >= total) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
combined, err := json.Marshal(accumulated)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to combine Harbor pages: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(combined, target); err != nil {
|
||||||
|
return fmt.Errorf("failed to decode Harbor pages: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) harborGet(ctx context.Context, registry *entity.Registry, path string, query url.Values) ([]byte, int, error) {
|
||||||
|
baseURL, err := harborBaseURL(registry)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
requestURL := strings.TrimRight(baseURL, "/") + path
|
||||||
|
if len(query) > 0 {
|
||||||
|
requestURL += "?" + query.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -1, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if registry.Username != "" || registry.Password != "" {
|
||||||
|
req.SetBasicAuth(registry.Username, registry.Password)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, -1, fmt.Errorf("Harbor API request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, -1, fmt.Errorf("failed to read Harbor API response: %w", readErr)
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, -1, fmt.Errorf("Harbor API %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
total := -1
|
||||||
|
if value := strings.TrimSpace(resp.Header.Get("X-Total-Count")); value != "" {
|
||||||
|
if parsed, err := strconv.Atoi(value); err == nil {
|
||||||
|
total = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return body, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func harborBaseURL(registry *entity.Registry) (string, error) {
|
||||||
|
rawURL := strings.TrimSpace(registry.URL)
|
||||||
|
if rawURL == "" {
|
||||||
|
return "", fmt.Errorf("registry URL is empty")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rawURL, "://") {
|
||||||
|
rawURL = "https://" + rawURL
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid registry URL %q: %w", registry.URL, err)
|
||||||
|
}
|
||||||
|
if parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return "", fmt.Errorf("invalid registry URL %q", registry.URL)
|
||||||
|
}
|
||||||
|
return parsed.Scheme + "://" + parsed.Host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHarborRepository(repository string) (string, string, bool) {
|
||||||
|
projectName, repoName, ok := strings.Cut(strings.Trim(repository, "/"), "/")
|
||||||
|
if !ok || projectName == "" || repoName == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return projectName, repoName, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHarborRepositoryName(projectName, repositoryName string) string {
|
||||||
|
repositoryName = strings.Trim(repositoryName, "/")
|
||||||
|
if repositoryName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(repositoryName, projectName+"/") {
|
||||||
|
return repositoryName
|
||||||
|
}
|
||||||
|
return projectName + "/" + repositoryName
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHarborChartArtifact(artifact harborArtifact) bool {
|
||||||
|
typeInfo := strings.ToLower(strings.TrimSpace(artifact.ArtifactType + " " + artifact.MediaType))
|
||||||
|
return strings.Contains(typeInfo, "chart") || strings.Contains(typeInfo, "helm")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneValues(values url.Values) url.Values {
|
||||||
|
cloned := make(url.Values)
|
||||||
|
for key, items := range values {
|
||||||
|
cloned[key] = append([]string(nil), items...)
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseHarborTime(value string) time.Time {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"} {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
// ListArtifacts 列出指定 repository 的所有 artifacts
|
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||||
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||||
func (c *OCIClient) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
func (c *OCIClient) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||||
|
if artifacts, err := c.listHarborArtifacts(ctx, registry, repository, mediaTypeFilter); err == nil {
|
||||||
|
return artifacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
reg, err := c.getRegistry(registry)
|
reg, err := c.getRegistry(registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -370,6 +693,113 @@ func (c *OCIClient) GetValuesSchema(ctx context.Context, registry *entity.Regist
|
|||||||
return "", entity.ErrValuesSchemaNotFound
|
return "", entity.ErrValuesSchemaNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetValuesYAML 获取 Helm Chart 包内原始 values.yaml
|
||||||
|
func (c *OCIClient) GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
|
||||||
|
data, err := c.readChartFile(ctx, registry, repository, reference, "values.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(data) == "" {
|
||||||
|
return "", entity.ErrArtifactNotFound
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *OCIClient) readChartFile(ctx context.Context, registry *entity.Registry, repository, reference, filename string) (string, error) {
|
||||||
|
reg, err := c.getRegistry(registry)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := reg.Repository(ctx, repository)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "", fmt.Errorf("helm chart layer not found in manifest")
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
bestDepth := int(^uint(0) >> 1)
|
||||||
|
var bestData []byte
|
||||||
|
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, filename) {
|
||||||
|
data, err := io.ReadAll(tarReader)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
depth := strings.Count(strings.Trim(header.Name, "/"), "/")
|
||||||
|
if depth < bestDepth {
|
||||||
|
bestDepth = depth
|
||||||
|
bestData = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(bestData) > 0 {
|
||||||
|
return string(bestData), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("%s not found in chart", filename)
|
||||||
|
}
|
||||||
|
|
||||||
// PullArtifact 下载 artifact 到本地
|
// PullArtifact 下载 artifact 到本地
|
||||||
func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
|
func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
|
||||||
reg, err := c.getRegistry(registry)
|
reg, err := c.getRegistry(registry)
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package mock
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
@ -27,21 +27,21 @@ func NewClusterRepositoryMock(encryptor crypto.Encryptor) repository.ClusterRepo
|
|||||||
func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Cluster) error {
|
func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
// 检查名称是否已存在
|
// 检查名称是否已存在
|
||||||
for _, c := range r.clusters {
|
for _, c := range r.clusters {
|
||||||
if c.Name == cluster.Name {
|
if c.Name == cluster.Name {
|
||||||
return entity.ErrClusterExists
|
return entity.ErrClusterExists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock 模式:如果没有提供认证信息,自动填充默认的 Mock 证书
|
// Mock 模式:如果没有提供认证信息,自动填充默认的 Mock 证书
|
||||||
if (cluster.CertData == "" || cluster.KeyData == "") && cluster.Token == "" {
|
if (cluster.CertData == "" || cluster.KeyData == "") && cluster.Token == "" {
|
||||||
cluster.CAData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ0EgQ2VydGlmaWNhdGUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ=="
|
cluster.CAData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ0EgQ2VydGlmaWNhdGUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ=="
|
||||||
cluster.CertData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="
|
cluster.CertData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="
|
||||||
cluster.KeyData = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t"
|
cluster.KeyData = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加密敏感数据后存储
|
// 加密敏感数据后存储
|
||||||
encryptedCluster := r.encryptCluster(cluster)
|
encryptedCluster := r.encryptCluster(cluster)
|
||||||
r.clusters[cluster.ID] = encryptedCluster
|
r.clusters[cluster.ID] = encryptedCluster
|
||||||
@ -51,12 +51,12 @@ func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Clus
|
|||||||
func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
cluster, exists := r.clusters[id]
|
cluster, exists := r.clusters[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, entity.ErrClusterNotFound
|
return nil, entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解密敏感数据后返回
|
// 解密敏感数据后返回
|
||||||
return r.decryptCluster(cluster), nil
|
return r.decryptCluster(cluster), nil
|
||||||
}
|
}
|
||||||
@ -64,25 +64,25 @@ func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity
|
|||||||
func (r *ClusterRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
func (r *ClusterRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
for _, cluster := range r.clusters {
|
for _, cluster := range r.clusters {
|
||||||
if cluster.Name == name {
|
if cluster.Name == name {
|
||||||
// 解密敏感数据后返回
|
// 解密敏感数据后返回
|
||||||
return r.decryptCluster(cluster), nil
|
return r.decryptCluster(cluster), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, entity.ErrClusterNotFound
|
return nil, entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ClusterRepositoryMock) Update(ctx context.Context, cluster *entity.Cluster) error {
|
func (r *ClusterRepositoryMock) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.clusters[cluster.ID]; !exists {
|
if _, exists := r.clusters[cluster.ID]; !exists {
|
||||||
return entity.ErrClusterNotFound
|
return entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加密敏感数据后存储
|
// 加密敏感数据后存储
|
||||||
encryptedCluster := r.encryptCluster(cluster)
|
encryptedCluster := r.encryptCluster(cluster)
|
||||||
r.clusters[cluster.ID] = encryptedCluster
|
r.clusters[cluster.ID] = encryptedCluster
|
||||||
@ -92,11 +92,11 @@ func (r *ClusterRepositoryMock) Update(ctx context.Context, cluster *entity.Clus
|
|||||||
func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
|
func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.clusters[id]; !exists {
|
if _, exists := r.clusters[id]; !exists {
|
||||||
return entity.ErrClusterNotFound
|
return entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(r.clusters, id)
|
delete(r.clusters, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -104,20 +104,20 @@ func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
|
|||||||
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
|
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
clusters := make([]*entity.Cluster, 0, len(r.clusters))
|
clusters := make([]*entity.Cluster, 0, len(r.clusters))
|
||||||
for _, cluster := range r.clusters {
|
for _, cluster := range r.clusters {
|
||||||
// 解密敏感数据后返回
|
// 解密敏感数据后返回
|
||||||
clusters = append(clusters, r.decryptCluster(cluster))
|
clusters = append(clusters, r.decryptCluster(cluster))
|
||||||
}
|
}
|
||||||
|
|
||||||
return clusters, nil
|
return clusters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encryptCluster 加密 Cluster 的敏感数据
|
// encryptCluster 加密 Cluster 的敏感数据
|
||||||
func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||||
encrypted := *cluster // 复制
|
encrypted := *cluster // 复制
|
||||||
|
|
||||||
// 加密证书数据
|
// 加密证书数据
|
||||||
if cluster.CAData != "" && !crypto.IsEncrypted(cluster.CAData) {
|
if cluster.CAData != "" && !crypto.IsEncrypted(cluster.CAData) {
|
||||||
if encryptedData, err := r.encryptor.Encrypt(cluster.CAData); err == nil {
|
if encryptedData, err := r.encryptor.Encrypt(cluster.CAData); err == nil {
|
||||||
@ -139,14 +139,14 @@ func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.
|
|||||||
encrypted.Token = encryptedData
|
encrypted.Token = encryptedData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &encrypted
|
return &encrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
// decryptCluster 解密 Cluster 的敏感数据
|
// decryptCluster 解密 Cluster 的敏感数据
|
||||||
func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||||
decrypted := *cluster // 复制
|
decrypted := *cluster // 复制
|
||||||
|
|
||||||
// 解密证书数据
|
// 解密证书数据
|
||||||
if cluster.CAData != "" && crypto.IsEncrypted(cluster.CAData) {
|
if cluster.CAData != "" && crypto.IsEncrypted(cluster.CAData) {
|
||||||
if decryptedData, err := r.encryptor.Decrypt(cluster.CAData); err == nil {
|
if decryptedData, err := r.encryptor.Decrypt(cluster.CAData); err == nil {
|
||||||
@ -168,7 +168,6 @@ func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.
|
|||||||
decrypted.Token = decryptedData
|
decrypted.Token = decryptedData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &decrypted
|
return &decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package mock
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
)
|
)
|
||||||
@ -24,14 +24,14 @@ func NewInstanceRepositoryMock() repository.InstanceRepository {
|
|||||||
func (r *InstanceRepositoryMock) Create(ctx context.Context, instance *entity.Instance) error {
|
func (r *InstanceRepositoryMock) Create(ctx context.Context, instance *entity.Instance) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
// 检查同一集群中名称是否已存在
|
// 检查同一集群中名称是否已存在
|
||||||
for _, inst := range r.instances {
|
for _, inst := range r.instances {
|
||||||
if inst.ClusterID == instance.ClusterID && inst.Name == instance.Name {
|
if inst.ClusterID == instance.ClusterID && inst.Name == instance.Name {
|
||||||
return entity.ErrInstanceExists
|
return entity.ErrInstanceExists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r.instances[instance.ID] = instance
|
r.instances[instance.ID] = instance
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -39,36 +39,36 @@ func (r *InstanceRepositoryMock) Create(ctx context.Context, instance *entity.In
|
|||||||
func (r *InstanceRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
func (r *InstanceRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
instance, exists := r.instances[id]
|
instance, exists := r.instances[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, entity.ErrInstanceNotFound
|
return nil, entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance, nil
|
return instance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *InstanceRepositoryMock) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
func (r *InstanceRepositoryMock) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
for _, instance := range r.instances {
|
for _, instance := range r.instances {
|
||||||
if instance.ClusterID == clusterID && instance.Name == name {
|
if instance.ClusterID == clusterID && instance.Name == name {
|
||||||
return instance, nil
|
return instance, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, entity.ErrInstanceNotFound
|
return nil, entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *InstanceRepositoryMock) Update(ctx context.Context, instance *entity.Instance) error {
|
func (r *InstanceRepositoryMock) Update(ctx context.Context, instance *entity.Instance) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.instances[instance.ID]; !exists {
|
if _, exists := r.instances[instance.ID]; !exists {
|
||||||
return entity.ErrInstanceNotFound
|
return entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
r.instances[instance.ID] = instance
|
r.instances[instance.ID] = instance
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -76,11 +76,11 @@ func (r *InstanceRepositoryMock) Update(ctx context.Context, instance *entity.In
|
|||||||
func (r *InstanceRepositoryMock) Delete(ctx context.Context, id string) error {
|
func (r *InstanceRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.instances[id]; !exists {
|
if _, exists := r.instances[id]; !exists {
|
||||||
return entity.ErrInstanceNotFound
|
return entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(r.instances, id)
|
delete(r.instances, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -88,26 +88,25 @@ func (r *InstanceRepositoryMock) Delete(ctx context.Context, id string) error {
|
|||||||
func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
instances := make([]*entity.Instance, 0)
|
instances := make([]*entity.Instance, 0)
|
||||||
for _, instance := range r.instances {
|
for _, instance := range r.instances {
|
||||||
if instance.ClusterID == clusterID {
|
if instance.ClusterID == clusterID {
|
||||||
instances = append(instances, instance)
|
instances = append(instances, instance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return instances, nil
|
return instances, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance, error) {
|
func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
instances := make([]*entity.Instance, 0, len(r.instances))
|
instances := make([]*entity.Instance, 0, len(r.instances))
|
||||||
for _, instance := range r.instances {
|
for _, instance := range r.instances {
|
||||||
instances = append(instances, instance)
|
instances = append(instances, instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
return instances, nil
|
return instances, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package mock
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
@ -27,14 +27,14 @@ func NewRegistryRepositoryMock(encryptor crypto.Encryptor) repository.RegistryRe
|
|||||||
func (r *RegistryRepositoryMock) Create(ctx context.Context, registry *entity.Registry) error {
|
func (r *RegistryRepositoryMock) Create(ctx context.Context, registry *entity.Registry) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
// 检查名称是否已存在
|
// 检查名称是否已存在
|
||||||
for _, reg := range r.registries {
|
for _, reg := range r.registries {
|
||||||
if reg.Name == registry.Name {
|
if reg.Name == registry.Name {
|
||||||
return entity.ErrRegistryExists
|
return entity.ErrRegistryExists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加密敏感数据后存储
|
// 加密敏感数据后存储
|
||||||
encryptedRegistry := r.encryptRegistry(registry)
|
encryptedRegistry := r.encryptRegistry(registry)
|
||||||
r.registries[registry.ID] = encryptedRegistry
|
r.registries[registry.ID] = encryptedRegistry
|
||||||
@ -44,12 +44,12 @@ func (r *RegistryRepositoryMock) Create(ctx context.Context, registry *entity.Re
|
|||||||
func (r *RegistryRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
func (r *RegistryRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
registry, exists := r.registries[id]
|
registry, exists := r.registries[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, entity.ErrRegistryNotFound
|
return nil, entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解密敏感数据后返回
|
// 解密敏感数据后返回
|
||||||
return r.decryptRegistry(registry), nil
|
return r.decryptRegistry(registry), nil
|
||||||
}
|
}
|
||||||
@ -57,25 +57,25 @@ func (r *RegistryRepositoryMock) GetByID(ctx context.Context, id string) (*entit
|
|||||||
func (r *RegistryRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
func (r *RegistryRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
for _, registry := range r.registries {
|
for _, registry := range r.registries {
|
||||||
if registry.Name == name {
|
if registry.Name == name {
|
||||||
// 解密敏感数据后返回
|
// 解密敏感数据后返回
|
||||||
return r.decryptRegistry(registry), nil
|
return r.decryptRegistry(registry), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, entity.ErrRegistryNotFound
|
return nil, entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *RegistryRepositoryMock) Update(ctx context.Context, registry *entity.Registry) error {
|
func (r *RegistryRepositoryMock) Update(ctx context.Context, registry *entity.Registry) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.registries[registry.ID]; !exists {
|
if _, exists := r.registries[registry.ID]; !exists {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加密敏感数据后存储
|
// 加密敏感数据后存储
|
||||||
encryptedRegistry := r.encryptRegistry(registry)
|
encryptedRegistry := r.encryptRegistry(registry)
|
||||||
r.registries[registry.ID] = encryptedRegistry
|
r.registries[registry.ID] = encryptedRegistry
|
||||||
@ -85,11 +85,11 @@ func (r *RegistryRepositoryMock) Update(ctx context.Context, registry *entity.Re
|
|||||||
func (r *RegistryRepositoryMock) Delete(ctx context.Context, id string) error {
|
func (r *RegistryRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.registries[id]; !exists {
|
if _, exists := r.registries[id]; !exists {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(r.registries, id)
|
delete(r.registries, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -97,41 +97,40 @@ func (r *RegistryRepositoryMock) Delete(ctx context.Context, id string) error {
|
|||||||
func (r *RegistryRepositoryMock) List(ctx context.Context) ([]*entity.Registry, error) {
|
func (r *RegistryRepositoryMock) List(ctx context.Context) ([]*entity.Registry, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
registries := make([]*entity.Registry, 0, len(r.registries))
|
registries := make([]*entity.Registry, 0, len(r.registries))
|
||||||
for _, registry := range r.registries {
|
for _, registry := range r.registries {
|
||||||
// 解密敏感数据后返回
|
// 解密敏感数据后返回
|
||||||
registries = append(registries, r.decryptRegistry(registry))
|
registries = append(registries, r.decryptRegistry(registry))
|
||||||
}
|
}
|
||||||
|
|
||||||
return registries, nil
|
return registries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encryptRegistry 加密 Registry 的敏感数据
|
// encryptRegistry 加密 Registry 的敏感数据
|
||||||
func (r *RegistryRepositoryMock) encryptRegistry(registry *entity.Registry) *entity.Registry {
|
func (r *RegistryRepositoryMock) encryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||||
encrypted := *registry // 复制
|
encrypted := *registry // 复制
|
||||||
|
|
||||||
// 加密密码
|
// 加密密码
|
||||||
if registry.Password != "" && !crypto.IsEncrypted(registry.Password) {
|
if registry.Password != "" && !crypto.IsEncrypted(registry.Password) {
|
||||||
if encryptedPassword, err := r.encryptor.Encrypt(registry.Password); err == nil {
|
if encryptedPassword, err := r.encryptor.Encrypt(registry.Password); err == nil {
|
||||||
encrypted.Password = encryptedPassword
|
encrypted.Password = encryptedPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &encrypted
|
return &encrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
// decryptRegistry 解密 Registry 的敏感数据
|
// decryptRegistry 解密 Registry 的敏感数据
|
||||||
func (r *RegistryRepositoryMock) decryptRegistry(registry *entity.Registry) *entity.Registry {
|
func (r *RegistryRepositoryMock) decryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||||
decrypted := *registry // 复制
|
decrypted := *registry // 复制
|
||||||
|
|
||||||
// 解密密码
|
// 解密密码
|
||||||
if registry.Password != "" && crypto.IsEncrypted(registry.Password) {
|
if registry.Password != "" && crypto.IsEncrypted(registry.Password) {
|
||||||
if decryptedPassword, err := r.encryptor.Decrypt(registry.Password); err == nil {
|
if decryptedPassword, err := r.encryptor.Decrypt(registry.Password); err == nil {
|
||||||
decrypted.Password = decryptedPassword
|
decrypted.Password = decryptedPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &decrypted
|
return &decrypted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package mock
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
)
|
)
|
||||||
@ -24,14 +24,14 @@ func NewUserRepositoryMock() repository.UserRepository {
|
|||||||
func (r *UserRepositoryMock) Create(ctx context.Context, user *entity.User) error {
|
func (r *UserRepositoryMock) Create(ctx context.Context, user *entity.User) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
// 检查是否已存在
|
// 检查是否已存在
|
||||||
for _, u := range r.users {
|
for _, u := range r.users {
|
||||||
if u.Username == user.Username {
|
if u.Username == user.Username {
|
||||||
return entity.ErrUserExists
|
return entity.ErrUserExists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r.users[user.ID] = user
|
r.users[user.ID] = user
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -39,36 +39,36 @@ func (r *UserRepositoryMock) Create(ctx context.Context, user *entity.User) erro
|
|||||||
func (r *UserRepositoryMock) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
func (r *UserRepositoryMock) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
user, exists := r.users[id]
|
user, exists := r.users[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, entity.ErrUserNotFound
|
return nil, entity.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepositoryMock) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
func (r *UserRepositoryMock) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
for _, user := range r.users {
|
for _, user := range r.users {
|
||||||
if user.Username == username {
|
if user.Username == username {
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, entity.ErrUserNotFound
|
return nil, entity.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepositoryMock) Update(ctx context.Context, user *entity.User) error {
|
func (r *UserRepositoryMock) Update(ctx context.Context, user *entity.User) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.users[user.ID]; !exists {
|
if _, exists := r.users[user.ID]; !exists {
|
||||||
return entity.ErrUserNotFound
|
return entity.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
r.users[user.ID] = user
|
r.users[user.ID] = user
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -76,11 +76,11 @@ func (r *UserRepositoryMock) Update(ctx context.Context, user *entity.User) erro
|
|||||||
func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
|
func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
if _, exists := r.users[id]; !exists {
|
if _, exists := r.users[id]; !exists {
|
||||||
return entity.ErrUserNotFound
|
return entity.ErrUserNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(r.users, id)
|
delete(r.users, id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -88,12 +88,11 @@ func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
|
|||||||
func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
|
func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
users := make([]*entity.User, 0, len(r.users))
|
users := make([]*entity.User, 0, len(r.users))
|
||||||
for _, user := range r.users {
|
for _, user := range r.users {
|
||||||
users = append(users, user)
|
users = append(users, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,162 @@
|
|||||||
|
package mock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceRepositoryMock struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
workspaces map[string]*entity.Workspace
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkspaceRepositoryMock() repository.WorkspaceRepository {
|
||||||
|
repo := &WorkspaceRepositoryMock{workspaces: make(map[string]*entity.Workspace)}
|
||||||
|
defaultWorkspace := entity.NewWorkspace(entity.DefaultWorkspaceName, "")
|
||||||
|
defaultWorkspace.ID = entity.DefaultWorkspaceID
|
||||||
|
repo.workspaces[defaultWorkspace.ID] = defaultWorkspace
|
||||||
|
return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepositoryMock) Create(ctx context.Context, workspace *entity.Workspace) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if workspace.ID == "" {
|
||||||
|
workspace.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
for _, existing := range r.workspaces {
|
||||||
|
if existing.Name == workspace.Name {
|
||||||
|
return entity.ErrWorkspaceExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
copy := *workspace
|
||||||
|
r.workspaces[workspace.ID] = ©
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
workspace, ok := r.workspaces[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
copy := *workspace
|
||||||
|
return ©, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, workspace := range r.workspaces {
|
||||||
|
if workspace.Name == name {
|
||||||
|
copy := *workspace
|
||||||
|
return ©, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepositoryMock) Update(ctx context.Context, workspace *entity.Workspace) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.workspaces[workspace.ID]; !ok {
|
||||||
|
return entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
copy := *workspace
|
||||||
|
r.workspaces[workspace.ID] = ©
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepositoryMock) List(ctx context.Context) ([]*entity.Workspace, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]*entity.Workspace, 0, len(r.workspaces))
|
||||||
|
for _, workspace := range r.workspaces {
|
||||||
|
copy := *workspace
|
||||||
|
result = append(result, ©)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceClusterBindingRepositoryMock struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
bindings map[string]*entity.WorkspaceClusterBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkspaceClusterBindingRepositoryMock() repository.WorkspaceClusterBindingRepository {
|
||||||
|
return &WorkspaceClusterBindingRepositoryMock{bindings: make(map[string]*entity.WorkspaceClusterBinding)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindingKey(workspaceID, clusterID string) string {
|
||||||
|
return workspaceID + "/" + clusterID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceClusterBindingRepositoryMock) Upsert(ctx context.Context, binding *entity.WorkspaceClusterBinding) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if binding.ID == "" {
|
||||||
|
binding.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
copy := *binding
|
||||||
|
r.bindings[bindingKey(binding.WorkspaceID, binding.ClusterID)] = ©
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceClusterBindingRepositoryMock) Get(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
binding, ok := r.bindings[bindingKey(workspaceID, clusterID)]
|
||||||
|
if !ok {
|
||||||
|
return nil, entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
copy := *binding
|
||||||
|
return ©, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceClusterBindingRepositoryMock) Delete(ctx context.Context, workspaceID, clusterID string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.bindings, bindingKey(workspaceID, clusterID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogRepositoryMock struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
logs []*entity.AuditLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditLogRepositoryMock() repository.AuditLogRepository {
|
||||||
|
return &AuditLogRepositoryMock{logs: make([]*entity.AuditLog, 0)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditLogRepositoryMock) Create(ctx context.Context, logEntry *entity.AuditLog) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if logEntry.ID == "" {
|
||||||
|
logEntry.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
copy := *logEntry
|
||||||
|
r.logs = append(r.logs, ©)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditLogRepositoryMock) ListByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
result := make([]*entity.AuditLog, 0)
|
||||||
|
for i := len(r.logs) - 1; i >= 0; i-- {
|
||||||
|
if r.logs[i].WorkspaceID == workspaceID {
|
||||||
|
copy := *r.logs[i]
|
||||||
|
result = append(result, ©)
|
||||||
|
if limit > 0 && len(result) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@ -12,54 +12,33 @@ import (
|
|||||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClusterRepository PostgreSQL 集群仓储实现
|
|
||||||
type ClusterRepository struct {
|
type ClusterRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
encryptor crypto.Encryptor
|
encryptor crypto.Encryptor
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClusterRepository 创建 PostgreSQL 集群仓储
|
|
||||||
func NewClusterRepository(db *DB, encryptor crypto.Encryptor) repository.ClusterRepository {
|
func NewClusterRepository(db *DB, encryptor crypto.Encryptor) repository.ClusterRepository {
|
||||||
return &ClusterRepository{
|
return &ClusterRepository{db: db, encryptor: encryptor}
|
||||||
db: db,
|
|
||||||
encryptor: encryptor,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建集群
|
|
||||||
func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster) error {
|
func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
if cluster.ID == "" {
|
if cluster.ID == "" {
|
||||||
cluster.ID = uuid.New().String()
|
cluster.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken, err := r.encryptClusterSecrets(cluster)
|
||||||
// 加密敏感数据
|
|
||||||
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt key data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO clusters (id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at)
|
INSERT INTO clusters
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
(id, workspace_id, owner_id, visibility, name, host, ca_data, cert_data, key_data, token, description, default_namespace, created_at, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = r.db.conn.ExecContext(ctx, query,
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
cluster.ID,
|
cluster.ID,
|
||||||
|
cluster.WorkspaceID,
|
||||||
|
cluster.OwnerID,
|
||||||
|
cluster.Visibility,
|
||||||
cluster.Name,
|
cluster.Name,
|
||||||
cluster.Host,
|
cluster.Host,
|
||||||
encryptedCAData,
|
encryptedCAData,
|
||||||
@ -67,160 +46,62 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
|
|||||||
encryptedKeyData,
|
encryptedKeyData,
|
||||||
encryptedToken,
|
encryptedToken,
|
||||||
cluster.Description,
|
cluster.Description,
|
||||||
|
cluster.DefaultNamespace,
|
||||||
cluster.CreatedAt,
|
cluster.CreatedAt,
|
||||||
cluster.UpdatedAt,
|
cluster.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create cluster: %w", err)
|
return fmt.Errorf("failed to create cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID 根据 ID 获取集群
|
|
||||||
func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||||
query := `
|
return r.get(ctx, "id = $1", id)
|
||||||
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) {
|
func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||||
query := `
|
return r.get(ctx, "name = $1", name)
|
||||||
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Cluster, error) {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id, workspace_id, owner_id, visibility, name, host, ca_data, cert_data, key_data, token, description, default_namespace, created_at, updated_at
|
||||||
FROM clusters
|
FROM clusters
|
||||||
WHERE name = $1
|
WHERE %s
|
||||||
`
|
`, where)
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, arg)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
// 解密敏感数据
|
if !rows.Next() {
|
||||||
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
|
return nil, entity.ErrClusterNotFound
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
|
|
||||||
}
|
}
|
||||||
|
cluster, err := r.scanCluster(rows)
|
||||||
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to decrypt token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cluster, nil
|
return cluster, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 更新集群
|
|
||||||
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
|
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
cluster.UpdatedAt = time.Now()
|
cluster.UpdatedAt = time.Now()
|
||||||
|
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken, err := r.encryptClusterSecrets(cluster)
|
||||||
// 加密敏感数据
|
|
||||||
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt key data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE clusters
|
UPDATE clusters
|
||||||
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
|
SET workspace_id = $1, owner_id = $2, visibility = $3, name = $4, host = $5,
|
||||||
token = $6, description = $7, updated_at = $8
|
ca_data = $6, cert_data = $7, key_data = $8, token = $9, description = $10,
|
||||||
WHERE id = $9
|
default_namespace = $11, updated_at = $12
|
||||||
|
WHERE id = $13
|
||||||
`
|
`
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query,
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
cluster.WorkspaceID,
|
||||||
|
cluster.OwnerID,
|
||||||
|
cluster.Visibility,
|
||||||
cluster.Name,
|
cluster.Name,
|
||||||
cluster.Host,
|
cluster.Host,
|
||||||
encryptedCAData,
|
encryptedCAData,
|
||||||
@ -228,110 +109,134 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
|
|||||||
encryptedKeyData,
|
encryptedKeyData,
|
||||||
encryptedToken,
|
encryptedToken,
|
||||||
cluster.Description,
|
cluster.Description,
|
||||||
|
cluster.DefaultNamespace,
|
||||||
cluster.UpdatedAt,
|
cluster.UpdatedAt,
|
||||||
cluster.ID,
|
cluster.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update cluster: %w", err)
|
return fmt.Errorf("failed to update cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
rows, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return entity.ErrClusterNotFound
|
return entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除集群
|
|
||||||
func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
|
func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
|
||||||
query := `DELETE FROM clusters WHERE id = $1`
|
result, err := r.db.conn.ExecContext(ctx, `DELETE FROM clusters WHERE id = $1`, id)
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete cluster: %w", err)
|
return fmt.Errorf("failed to delete cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
rows, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return entity.ErrClusterNotFound
|
return entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 列出所有集群
|
|
||||||
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
|
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
|
SELECT id, workspace_id, owner_id, visibility, name, host, ca_data, cert_data, key_data, token, description, default_namespace, created_at, updated_at
|
||||||
FROM clusters
|
FROM clusters
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list clusters: %w", err)
|
return nil, fmt.Errorf("failed to list clusters: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
clusters := make([]*entity.Cluster, 0)
|
clusters := make([]*entity.Cluster, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
cluster := &entity.Cluster{}
|
cluster, err := r.scanCluster(rows)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan cluster: %w", err)
|
return nil, 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)
|
clusters = append(clusters, cluster)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return clusters, nil
|
return clusters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type clusterScanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepository) scanCluster(scanner clusterScanner) (*entity.Cluster, error) {
|
||||||
|
cluster := &entity.Cluster{}
|
||||||
|
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken sql.NullString
|
||||||
|
var defaultNamespace sql.NullString
|
||||||
|
err := scanner.Scan(
|
||||||
|
&cluster.ID,
|
||||||
|
&cluster.WorkspaceID,
|
||||||
|
&cluster.OwnerID,
|
||||||
|
&cluster.Visibility,
|
||||||
|
&cluster.Name,
|
||||||
|
&cluster.Host,
|
||||||
|
&encryptedCAData,
|
||||||
|
&encryptedCertData,
|
||||||
|
&encryptedKeyData,
|
||||||
|
&encryptedToken,
|
||||||
|
&cluster.Description,
|
||||||
|
&defaultNamespace,
|
||||||
|
&cluster.CreatedAt,
|
||||||
|
&cluster.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan cluster: %w", err)
|
||||||
|
}
|
||||||
|
cluster.DefaultNamespace = defaultNamespace.String
|
||||||
|
var decryptErr error
|
||||||
|
cluster.CAData, decryptErr = decryptMaybe(r.encryptor, encryptedCAData.String)
|
||||||
|
if decryptErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt CA data: %w", decryptErr)
|
||||||
|
}
|
||||||
|
cluster.CertData, decryptErr = decryptMaybe(r.encryptor, encryptedCertData.String)
|
||||||
|
if decryptErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt cert data: %w", decryptErr)
|
||||||
|
}
|
||||||
|
cluster.KeyData, decryptErr = decryptMaybe(r.encryptor, encryptedKeyData.String)
|
||||||
|
if decryptErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt key data: %w", decryptErr)
|
||||||
|
}
|
||||||
|
cluster.Token, decryptErr = decryptMaybe(r.encryptor, encryptedToken.String)
|
||||||
|
if decryptErr != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt token: %w", decryptErr)
|
||||||
|
}
|
||||||
|
return cluster, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ClusterRepository) encryptClusterSecrets(cluster *entity.Cluster) (string, string, string, string, error) {
|
||||||
|
ca, err := r.encryptor.Encrypt(cluster.CAData)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||||
|
}
|
||||||
|
cert, err := r.encryptor.Encrypt(cluster.CertData)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||||
|
}
|
||||||
|
key, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("failed to encrypt key data: %w", err)
|
||||||
|
}
|
||||||
|
token, err := r.encryptor.Encrypt(cluster.Token)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", fmt.Errorf("failed to encrypt token: %w", err)
|
||||||
|
}
|
||||||
|
return ca, cert, key, token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptMaybe(encryptor crypto.Encryptor, value string) (string, error) {
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return encryptor.Decrypt(value)
|
||||||
|
}
|
||||||
|
|||||||
@ -53,21 +53,69 @@ func (db *DB) GetConn() *sql.DB {
|
|||||||
// InitSchema 初始化数据库 schema
|
// InitSchema 初始化数据库 schema
|
||||||
func (db *DB) InitSchema() error {
|
func (db *DB) InitSchema() error {
|
||||||
schema := `
|
schema := `
|
||||||
|
-- Workspaces 表
|
||||||
|
CREATE TABLE IF NOT EXISTS workspaces (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||||
|
k8s_namespace VARCHAR(255) NOT NULL,
|
||||||
|
k8s_sa_name VARCHAR(255) NOT NULL,
|
||||||
|
default_cluster_id VARCHAR(36),
|
||||||
|
quota_cpu VARCHAR(50),
|
||||||
|
quota_memory VARCHAR(50),
|
||||||
|
quota_gpu VARCHAR(50),
|
||||||
|
quota_gpu_memory VARCHAR(50),
|
||||||
|
created_by VARCHAR(36),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE workspaces
|
||||||
|
ADD COLUMN IF NOT EXISTS default_cluster_id VARCHAR(36),
|
||||||
|
ADD COLUMN IF NOT EXISTS quota_cpu VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS quota_memory VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS quota_gpu VARCHAR(50),
|
||||||
|
ADD COLUMN IF NOT EXISTS quota_gpu_memory VARCHAR(50);
|
||||||
|
|
||||||
|
INSERT INTO workspaces (id, name, status, k8s_namespace, k8s_sa_name, created_at, updated_at)
|
||||||
|
VALUES ('00000000-0000-0000-0000-000000000010', 'default', 'active', 'ocdp-ws-default', 'ocdp-ws-default', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- Users 表
|
-- Users 表
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
username VARCHAR(255) NOT NULL UNIQUE,
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
email VARCHAR(255) NOT NULL,
|
email VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||||
|
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
revoked_after TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||||
|
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS revoked_after TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00';
|
||||||
|
|
||||||
|
UPDATE users SET role = 'admin' WHERE username = 'admin';
|
||||||
|
UPDATE users SET workspace_id = '00000000-0000-0000-0000-000000000010' WHERE workspace_id = '';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_workspace ON users(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_revoked_after ON users(revoked_after);
|
||||||
|
|
||||||
-- Clusters 表
|
-- Clusters 表
|
||||||
CREATE TABLE IF NOT EXISTS clusters (
|
CREATE TABLE IF NOT EXISTS clusters (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||||
|
visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
host TEXT NOT NULL,
|
host TEXT NOT NULL,
|
||||||
ca_data TEXT,
|
ca_data TEXT,
|
||||||
@ -75,15 +123,29 @@ func (db *DB) InitSchema() error {
|
|||||||
key_data TEXT,
|
key_data TEXT,
|
||||||
token TEXT,
|
token TEXT,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
default_namespace VARCHAR(255),
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE clusters
|
||||||
|
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
ADD COLUMN IF NOT EXISTS owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||||
|
ADD COLUMN IF NOT EXISTS default_namespace VARCHAR(255);
|
||||||
|
UPDATE clusters SET visibility = 'global_shared' WHERE visibility = 'private' AND owner_id = '';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);
|
CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clusters_workspace ON clusters(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clusters_owner ON clusters(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clusters_visibility ON clusters(visibility);
|
||||||
|
|
||||||
-- Registries 表
|
-- Registries 表
|
||||||
CREATE TABLE IF NOT EXISTS registries (
|
CREATE TABLE IF NOT EXISTS registries (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||||
|
visibility VARCHAR(50) NOT NULL DEFAULT 'private',
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
url TEXT NOT NULL,
|
url TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
@ -94,11 +156,22 @@ func (db *DB) InitSchema() error {
|
|||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE registries
|
||||||
|
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
ADD COLUMN IF NOT EXISTS owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS visibility VARCHAR(50) NOT NULL DEFAULT 'private';
|
||||||
|
UPDATE registries SET visibility = 'global_shared' WHERE visibility = 'private' AND owner_id = '';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_registries_name ON registries(name);
|
CREATE INDEX IF NOT EXISTS idx_registries_name ON registries(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registries_workspace ON registries(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registries_owner ON registries(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_registries_visibility ON registries(visibility);
|
||||||
|
|
||||||
-- Instances 表
|
-- Instances 表
|
||||||
CREATE TABLE IF NOT EXISTS instances (
|
CREATE TABLE IF NOT EXISTS instances (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
owner_id VARCHAR(36) NOT NULL DEFAULT '',
|
||||||
cluster_id VARCHAR(36) NOT NULL,
|
cluster_id VARCHAR(36) NOT NULL,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
namespace VARCHAR(255) NOT NULL,
|
namespace VARCHAR(255) NOT NULL,
|
||||||
@ -121,9 +194,63 @@ func (db *DB) InitSchema() error {
|
|||||||
CONSTRAINT unique_cluster_name UNIQUE (cluster_id, name, namespace)
|
CONSTRAINT unique_cluster_name UNIQUE (cluster_id, name, namespace)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE instances
|
||||||
|
ADD COLUMN IF NOT EXISTS workspace_id VARCHAR(36) NOT NULL DEFAULT '00000000-0000-0000-0000-000000000010',
|
||||||
|
ADD COLUMN IF NOT EXISTS owner_id VARCHAR(36) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_instances_cluster ON instances(cluster_id);
|
CREATE INDEX IF NOT EXISTS idx_instances_cluster ON instances(cluster_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_instances_registry ON instances(registry_id);
|
CREATE INDEX IF NOT EXISTS idx_instances_registry ON instances(registry_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
|
CREATE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instances_workspace ON instances(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_instances_owner ON instances(owner_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_cluster_bindings (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36) NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
cluster_id VARCHAR(36) NOT NULL REFERENCES clusters(id) ON DELETE CASCADE,
|
||||||
|
namespace VARCHAR(255) NOT NULL,
|
||||||
|
service_account VARCHAR(255) NOT NULL,
|
||||||
|
quota_cpu VARCHAR(50),
|
||||||
|
quota_memory VARCHAR(50),
|
||||||
|
quota_gpu VARCHAR(50),
|
||||||
|
quota_gpu_memory VARCHAR(50),
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (workspace_id, cluster_id)
|
||||||
|
);
|
||||||
|
ALTER TABLE workspace_cluster_bindings
|
||||||
|
ADD COLUMN IF NOT EXISTS quota_gpu_memory VARCHAR(50);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_cluster_bindings_workspace ON workspace_cluster_bindings(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_cluster_bindings_cluster ON workspace_cluster_bindings(cluster_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_quotas (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36) NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
resource_type VARCHAR(50) NOT NULL,
|
||||||
|
hard_limit VARCHAR(100) NOT NULL,
|
||||||
|
soft_limit VARCHAR(100),
|
||||||
|
used VARCHAR(100),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (workspace_id, resource_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36),
|
||||||
|
user_id VARCHAR(36),
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
resource_type VARCHAR(50) NOT NULL,
|
||||||
|
resource_id VARCHAR(36),
|
||||||
|
resource_name VARCHAR(255),
|
||||||
|
details JSONB,
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_workspace ON audit_logs(workspace_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id);
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := db.conn.Exec(schema)
|
_, err := db.conn.Exec(schema)
|
||||||
|
|||||||
@ -12,37 +12,32 @@ import (
|
|||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstanceRepository PostgreSQL 实例仓储实现
|
|
||||||
type InstanceRepository struct {
|
type InstanceRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInstanceRepository 创建 PostgreSQL 实例仓储
|
|
||||||
func NewInstanceRepository(db *DB) repository.InstanceRepository {
|
func NewInstanceRepository(db *DB) repository.InstanceRepository {
|
||||||
return &InstanceRepository{db: db}
|
return &InstanceRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建实例
|
|
||||||
func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instance) error {
|
func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instance) error {
|
||||||
if instance.ID == "" {
|
if instance.ID == "" {
|
||||||
instance.ID = uuid.New().String()
|
instance.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 Values 转换为 JSON
|
|
||||||
valuesJSON, err := json.Marshal(instance.Values)
|
valuesJSON, err := json.Marshal(instance.Values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal values: %w", err)
|
return fmt.Errorf("failed to marshal values: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO instances (id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
INSERT INTO instances
|
||||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
(id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
revision, created_at, updated_at)
|
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)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = r.db.conn.ExecContext(ctx, query,
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
instance.ID,
|
instance.ID,
|
||||||
|
instance.WorkspaceID,
|
||||||
|
instance.OwnerID,
|
||||||
instance.ClusterID,
|
instance.ClusterID,
|
||||||
instance.Name,
|
instance.Name,
|
||||||
instance.Namespace,
|
instance.Namespace,
|
||||||
@ -61,166 +56,71 @@ func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instan
|
|||||||
instance.CreatedAt,
|
instance.CreatedAt,
|
||||||
instance.UpdatedAt,
|
instance.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create instance: %w", err)
|
return fmt.Errorf("failed to create instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID 根据 ID 获取实例
|
|
||||||
func (r *InstanceRepository) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
func (r *InstanceRepository) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
query := `
|
return r.get(ctx, "id = $1", id)
|
||||||
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) {
|
func (r *InstanceRepository) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
SELECT id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
revision, created_at, updated_at
|
revision, created_at, updated_at
|
||||||
FROM instances
|
FROM instances
|
||||||
WHERE cluster_id = $1 AND name = $2
|
WHERE cluster_id = $1 AND name = $2
|
||||||
`
|
`
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, clusterID, name)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get instance: %w", err)
|
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
// 解析 JSON Values
|
if !rows.Next() {
|
||||||
if len(valuesJSON) > 0 {
|
return nil, entity.ErrInstanceNotFound
|
||||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return r.scanInstance(rows)
|
||||||
if statusReason.Valid {
|
}
|
||||||
instance.StatusReason = statusReason.String
|
|
||||||
}
|
func (r *InstanceRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Instance, error) {
|
||||||
if lastOperation.Valid {
|
query := fmt.Sprintf(`
|
||||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
SELECT id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
}
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
if lastError.Valid {
|
revision, created_at, updated_at
|
||||||
instance.LastError = lastError.String
|
FROM instances
|
||||||
}
|
WHERE %s
|
||||||
|
`, where)
|
||||||
return instance, nil
|
rows, err := r.db.conn.QueryContext(ctx, query, arg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
if !rows.Next() {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
return r.scanInstance(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 更新实例
|
|
||||||
func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instance) error {
|
func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instance) error {
|
||||||
instance.UpdatedAt = time.Now()
|
instance.UpdatedAt = time.Now()
|
||||||
|
|
||||||
// 将 Values 转换为 JSON
|
|
||||||
valuesJSON, err := json.Marshal(instance.Values)
|
valuesJSON, err := json.Marshal(instance.Values)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal values: %w", err)
|
return fmt.Errorf("failed to marshal values: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE instances
|
UPDATE instances
|
||||||
SET cluster_id = $1, name = $2, namespace = $3, registry_id = $4, repository = $5,
|
SET workspace_id = $1, owner_id = $2, cluster_id = $3, name = $4, namespace = $5,
|
||||||
chart = $6, version = $7, description = $8, values = $9, values_yaml = $10,
|
registry_id = $6, repository = $7, chart = $8, version = $9, description = $10,
|
||||||
status = $11, status_reason = $12, last_operation = $13, last_error = $14,
|
values = $11, values_yaml = $12, status = $13, status_reason = $14,
|
||||||
revision = $15, updated_at = $16
|
last_operation = $15, last_error = $16, revision = $17, updated_at = $18
|
||||||
WHERE id = $17
|
WHERE id = $19
|
||||||
`
|
`
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query,
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
instance.WorkspaceID,
|
||||||
|
instance.OwnerID,
|
||||||
instance.ClusterID,
|
instance.ClusterID,
|
||||||
instance.Name,
|
instance.Name,
|
||||||
instance.Namespace,
|
instance.Namespace,
|
||||||
@ -239,195 +139,126 @@ func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instan
|
|||||||
instance.UpdatedAt,
|
instance.UpdatedAt,
|
||||||
instance.ID,
|
instance.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update instance: %w", err)
|
return fmt.Errorf("failed to update instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
rows, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return entity.ErrInstanceNotFound
|
return entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除实例
|
|
||||||
func (r *InstanceRepository) Delete(ctx context.Context, id string) error {
|
func (r *InstanceRepository) Delete(ctx context.Context, id string) error {
|
||||||
query := `DELETE FROM instances WHERE id = $1`
|
result, err := r.db.conn.ExecContext(ctx, `DELETE FROM instances WHERE id = $1`, id)
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete instance: %w", err)
|
return fmt.Errorf("failed to delete instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
rows, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return entity.ErrInstanceNotFound
|
return entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListByCluster 列出指定集群的所有实例
|
|
||||||
func (r *InstanceRepository) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
func (r *InstanceRepository) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||||
query := `
|
return r.list(ctx, "WHERE cluster_id = $1", clusterID)
|
||||||
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) {
|
func (r *InstanceRepository) List(ctx context.Context) ([]*entity.Instance, error) {
|
||||||
|
return r.list(ctx, "", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepository) list(ctx context.Context, where string, arg interface{}) ([]*entity.Instance, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
SELECT id, workspace_id, owner_id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||||
revision, created_at, updated_at
|
revision, created_at, updated_at
|
||||||
FROM instances
|
FROM instances
|
||||||
|
` + where + `
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
|
var rows *sql.Rows
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
var err error
|
||||||
|
if where == "" {
|
||||||
|
rows, err = r.db.conn.QueryContext(ctx, query)
|
||||||
|
} else {
|
||||||
|
rows, err = r.db.conn.QueryContext(ctx, query, arg)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list instances: %w", err)
|
return nil, fmt.Errorf("failed to list instances: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
instances := make([]*entity.Instance, 0)
|
instances := make([]*entity.Instance, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
instance := &entity.Instance{}
|
instance, err := r.scanInstance(rows)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 JSON Values
|
|
||||||
if len(valuesJSON) > 0 {
|
|
||||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if statusReason.Valid {
|
|
||||||
instance.StatusReason = statusReason.String
|
|
||||||
}
|
|
||||||
if lastOperation.Valid {
|
|
||||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
|
||||||
}
|
|
||||||
if lastError.Valid {
|
|
||||||
instance.LastError = lastError.String
|
|
||||||
}
|
|
||||||
|
|
||||||
instances = append(instances, instance)
|
instances = append(instances, instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return instances, nil
|
return instances, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type instanceScanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InstanceRepository) scanInstance(scanner instanceScanner) (*entity.Instance, error) {
|
||||||
|
instance := &entity.Instance{}
|
||||||
|
var (
|
||||||
|
valuesJSON []byte
|
||||||
|
statusReason sql.NullString
|
||||||
|
lastOperation sql.NullString
|
||||||
|
lastError sql.NullString
|
||||||
|
)
|
||||||
|
err := scanner.Scan(
|
||||||
|
&instance.ID,
|
||||||
|
&instance.WorkspaceID,
|
||||||
|
&instance.OwnerID,
|
||||||
|
&instance.ClusterID,
|
||||||
|
&instance.Name,
|
||||||
|
&instance.Namespace,
|
||||||
|
&instance.RegistryID,
|
||||||
|
&instance.Repository,
|
||||||
|
&instance.Chart,
|
||||||
|
&instance.Version,
|
||||||
|
&instance.Description,
|
||||||
|
&valuesJSON,
|
||||||
|
&instance.ValuesYAML,
|
||||||
|
&instance.Status,
|
||||||
|
&statusReason,
|
||||||
|
&lastOperation,
|
||||||
|
&lastError,
|
||||||
|
&instance.Revision,
|
||||||
|
&instance.CreatedAt,
|
||||||
|
&instance.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||||
|
}
|
||||||
|
if len(valuesJSON) > 0 {
|
||||||
|
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|||||||
@ -12,39 +12,32 @@ import (
|
|||||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegistryRepository PostgreSQL Registry 仓储实现
|
|
||||||
type RegistryRepository struct {
|
type RegistryRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
encryptor crypto.Encryptor
|
encryptor crypto.Encryptor
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistryRepository 创建 PostgreSQL Registry 仓储
|
|
||||||
func NewRegistryRepository(db *DB, encryptor crypto.Encryptor) repository.RegistryRepository {
|
func NewRegistryRepository(db *DB, encryptor crypto.Encryptor) repository.RegistryRepository {
|
||||||
return &RegistryRepository{
|
return &RegistryRepository{db: db, encryptor: encryptor}
|
||||||
db: db,
|
|
||||||
encryptor: encryptor,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建 Registry
|
|
||||||
func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Registry) error {
|
func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Registry) error {
|
||||||
if registry.ID == "" {
|
if registry.ID == "" {
|
||||||
registry.ID = uuid.New().String()
|
registry.ID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加密密码
|
|
||||||
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO registries (id, name, url, description, username, password, insecure, created_at, updated_at)
|
INSERT INTO registries (id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = r.db.conn.ExecContext(ctx, query,
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
registry.ID,
|
registry.ID,
|
||||||
|
registry.WorkspaceID,
|
||||||
|
registry.OwnerID,
|
||||||
|
registry.Visibility,
|
||||||
registry.Name,
|
registry.Name,
|
||||||
registry.URL,
|
registry.URL,
|
||||||
registry.Description,
|
registry.Description,
|
||||||
@ -54,110 +47,57 @@ func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Regist
|
|||||||
registry.CreatedAt,
|
registry.CreatedAt,
|
||||||
registry.UpdatedAt,
|
registry.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create registry: %w", err)
|
return fmt.Errorf("failed to create registry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetByID 根据 ID 获取 Registry
|
|
||||||
func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||||
query := `
|
return r.get(ctx, "id = $1", id)
|
||||||
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) {
|
func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||||
query := `
|
return r.get(ctx, "name = $1", name)
|
||||||
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Registry, error) {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at
|
||||||
FROM registries
|
FROM registries
|
||||||
WHERE name = $1
|
WHERE %s
|
||||||
`
|
`, where)
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, arg)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get registry: %w", err)
|
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||||
}
|
}
|
||||||
|
defer rows.Close()
|
||||||
// 解密密码
|
if !rows.Next() {
|
||||||
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
|
return nil, entity.ErrRegistryNotFound
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
registry, err := r.scanRegistry(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return registry, nil
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update 更新 Registry
|
|
||||||
func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Registry) error {
|
func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Registry) error {
|
||||||
registry.UpdatedAt = time.Now()
|
registry.UpdatedAt = time.Now()
|
||||||
|
|
||||||
// 加密密码
|
|
||||||
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE registries
|
UPDATE registries
|
||||||
SET name = $1, url = $2, description = $3, username = $4, password = $5,
|
SET workspace_id = $1, owner_id = $2, visibility = $3, name = $4, url = $5,
|
||||||
insecure = $6, updated_at = $7
|
description = $6, username = $7, password = $8, insecure = $9, updated_at = $10
|
||||||
WHERE id = $8
|
WHERE id = $11
|
||||||
`
|
`
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query,
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
registry.WorkspaceID,
|
||||||
|
registry.OwnerID,
|
||||||
|
registry.Visibility,
|
||||||
registry.Name,
|
registry.Name,
|
||||||
registry.URL,
|
registry.URL,
|
||||||
registry.Description,
|
registry.Description,
|
||||||
@ -167,91 +107,86 @@ func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Regist
|
|||||||
registry.UpdatedAt,
|
registry.UpdatedAt,
|
||||||
registry.ID,
|
registry.ID,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update registry: %w", err)
|
return fmt.Errorf("failed to update registry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
rows, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete 删除 Registry
|
|
||||||
func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
|
func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
|
||||||
query := `DELETE FROM registries WHERE id = $1`
|
result, err := r.db.conn.ExecContext(ctx, `DELETE FROM registries WHERE id = $1`, id)
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete registry: %w", err)
|
return fmt.Errorf("failed to delete registry: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
rows, err := result.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 列出所有 Registries
|
|
||||||
func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) {
|
func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
|
SELECT id, workspace_id, owner_id, visibility, name, url, description, username, password, insecure, created_at, updated_at
|
||||||
FROM registries
|
FROM registries
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list registries: %w", err)
|
return nil, fmt.Errorf("failed to list registries: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
registries := make([]*entity.Registry, 0)
|
registries := make([]*entity.Registry, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
registry := &entity.Registry{}
|
registry, err := r.scanRegistry(rows)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan registry: %w", err)
|
return nil, 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)
|
registries = append(registries, registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return registries, nil
|
return registries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type registryScanner interface {
|
||||||
|
Scan(dest ...interface{}) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RegistryRepository) scanRegistry(scanner registryScanner) (*entity.Registry, error) {
|
||||||
|
registry := &entity.Registry{}
|
||||||
|
var encryptedPassword sql.NullString
|
||||||
|
err := scanner.Scan(
|
||||||
|
®istry.ID,
|
||||||
|
®istry.WorkspaceID,
|
||||||
|
®istry.OwnerID,
|
||||||
|
®istry.Visibility,
|
||||||
|
®istry.Name,
|
||||||
|
®istry.URL,
|
||||||
|
®istry.Description,
|
||||||
|
®istry.Username,
|
||||||
|
&encryptedPassword,
|
||||||
|
®istry.Insecure,
|
||||||
|
®istry.CreatedAt,
|
||||||
|
®istry.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan registry: %w", err)
|
||||||
|
}
|
||||||
|
registry.Password, err = decryptMaybe(r.encryptor, encryptedPassword.String)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||||
|
}
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -28,8 +28,8 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO users (id, username, password_hash, email, revoked_after, created_at, updated_at)
|
INSERT INTO users (id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err := r.db.conn.ExecContext(ctx, query,
|
_, err := r.db.conn.ExecContext(ctx, query,
|
||||||
@ -37,6 +37,10 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
|
|||||||
user.Username,
|
user.Username,
|
||||||
user.PasswordHash,
|
user.PasswordHash,
|
||||||
user.Email,
|
user.Email,
|
||||||
|
user.Role,
|
||||||
|
user.WorkspaceID,
|
||||||
|
user.IsActive,
|
||||||
|
user.MustChangePassword,
|
||||||
user.RevokedAfter,
|
user.RevokedAfter,
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
user.UpdatedAt,
|
user.UpdatedAt,
|
||||||
@ -52,7 +56,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
|
|||||||
// GetByID 根据 ID 获取用户
|
// GetByID 根据 ID 获取用户
|
||||||
func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
@ -63,6 +67,10 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
|
|||||||
&user.Username,
|
&user.Username,
|
||||||
&user.PasswordHash,
|
&user.PasswordHash,
|
||||||
&user.Email,
|
&user.Email,
|
||||||
|
&user.Role,
|
||||||
|
&user.WorkspaceID,
|
||||||
|
&user.IsActive,
|
||||||
|
&user.MustChangePassword,
|
||||||
&user.RevokedAfter,
|
&user.RevokedAfter,
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.UpdatedAt,
|
||||||
@ -81,7 +89,7 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
|
|||||||
// GetByUsername 根据用户名获取用户
|
// GetByUsername 根据用户名获取用户
|
||||||
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
WHERE username = $1
|
WHERE username = $1
|
||||||
`
|
`
|
||||||
@ -92,6 +100,10 @@ func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*e
|
|||||||
&user.Username,
|
&user.Username,
|
||||||
&user.PasswordHash,
|
&user.PasswordHash,
|
||||||
&user.Email,
|
&user.Email,
|
||||||
|
&user.Role,
|
||||||
|
&user.WorkspaceID,
|
||||||
|
&user.IsActive,
|
||||||
|
&user.MustChangePassword,
|
||||||
&user.RevokedAfter,
|
&user.RevokedAfter,
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.UpdatedAt,
|
||||||
@ -113,14 +125,19 @@ func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
|
|||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET username = $1, password_hash = $2, email = $3, revoked_after = $4, updated_at = $5
|
SET username = $1, password_hash = $2, email = $3, role = $4, workspace_id = $5,
|
||||||
WHERE id = $6
|
is_active = $6, must_change_password = $7, revoked_after = $8, updated_at = $9
|
||||||
|
WHERE id = $10
|
||||||
`
|
`
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query,
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
user.Username,
|
user.Username,
|
||||||
user.PasswordHash,
|
user.PasswordHash,
|
||||||
user.Email,
|
user.Email,
|
||||||
|
user.Role,
|
||||||
|
user.WorkspaceID,
|
||||||
|
user.IsActive,
|
||||||
|
user.MustChangePassword,
|
||||||
user.RevokedAfter,
|
user.RevokedAfter,
|
||||||
user.UpdatedAt,
|
user.UpdatedAt,
|
||||||
user.ID,
|
user.ID,
|
||||||
@ -166,7 +183,7 @@ func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
|||||||
// List 列出所有用户
|
// List 列出所有用户
|
||||||
func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
|
func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@ -185,6 +202,10 @@ func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
|
|||||||
&user.Username,
|
&user.Username,
|
||||||
&user.PasswordHash,
|
&user.PasswordHash,
|
||||||
&user.Email,
|
&user.Email,
|
||||||
|
&user.Role,
|
||||||
|
&user.WorkspaceID,
|
||||||
|
&user.IsActive,
|
||||||
|
&user.MustChangePassword,
|
||||||
&user.RevokedAfter,
|
&user.RevokedAfter,
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
&user.UpdatedAt,
|
&user.UpdatedAt,
|
||||||
@ -201,4 +222,3 @@ func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
|
|||||||
|
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,345 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkspaceRepository(db *DB) repository.WorkspaceRepository {
|
||||||
|
return &WorkspaceRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepository) Create(ctx context.Context, workspace *entity.Workspace) error {
|
||||||
|
if workspace.ID == "" {
|
||||||
|
workspace.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
INSERT INTO workspaces (id, name, status, k8s_namespace, k8s_sa_name, default_cluster_id, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, created_by, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
`
|
||||||
|
_, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
workspace.ID,
|
||||||
|
workspace.Name,
|
||||||
|
workspace.Status,
|
||||||
|
workspace.K8sNamespace,
|
||||||
|
workspace.K8sSAName,
|
||||||
|
workspace.DefaultClusterID,
|
||||||
|
workspace.QuotaCPU,
|
||||||
|
workspace.QuotaMemory,
|
||||||
|
workspace.QuotaGPU,
|
||||||
|
workspace.QuotaGPUMem,
|
||||||
|
workspace.CreatedBy,
|
||||||
|
workspace.CreatedAt,
|
||||||
|
workspace.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create workspace: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepository) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||||||
|
return r.get(ctx, "id = $1", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepository) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
|
||||||
|
return r.get(ctx, "name = $1", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepository) get(ctx context.Context, where string, arg interface{}) (*entity.Workspace, error) {
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT id, name, status, k8s_namespace, k8s_sa_name, default_cluster_id, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, created_by, created_at, updated_at
|
||||||
|
FROM workspaces
|
||||||
|
WHERE %s
|
||||||
|
`, where)
|
||||||
|
workspace := &entity.Workspace{}
|
||||||
|
var createdBy, defaultClusterID, quotaCPU, quotaMemory, quotaGPU, quotaGPUMem sql.NullString
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, arg).Scan(
|
||||||
|
&workspace.ID,
|
||||||
|
&workspace.Name,
|
||||||
|
&workspace.Status,
|
||||||
|
&workspace.K8sNamespace,
|
||||||
|
&workspace.K8sSAName,
|
||||||
|
&defaultClusterID,
|
||||||
|
"aCPU,
|
||||||
|
"aMemory,
|
||||||
|
"aGPU,
|
||||||
|
"aGPUMem,
|
||||||
|
&createdBy,
|
||||||
|
&workspace.CreatedAt,
|
||||||
|
&workspace.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get workspace: %w", err)
|
||||||
|
}
|
||||||
|
workspace.CreatedBy = createdBy.String
|
||||||
|
workspace.DefaultClusterID = defaultClusterID.String
|
||||||
|
workspace.QuotaCPU = quotaCPU.String
|
||||||
|
workspace.QuotaMemory = quotaMemory.String
|
||||||
|
workspace.QuotaGPU = quotaGPU.String
|
||||||
|
workspace.QuotaGPUMem = quotaGPUMem.String
|
||||||
|
return workspace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepository) Update(ctx context.Context, workspace *entity.Workspace) error {
|
||||||
|
workspace.UpdatedAt = time.Now()
|
||||||
|
query := `
|
||||||
|
UPDATE workspaces
|
||||||
|
SET name = $1, status = $2, k8s_namespace = $3, k8s_sa_name = $4,
|
||||||
|
default_cluster_id = $5,
|
||||||
|
quota_cpu = $6, quota_memory = $7, quota_gpu = $8, quota_gpu_memory = $9,
|
||||||
|
created_by = $10, updated_at = $11
|
||||||
|
WHERE id = $12
|
||||||
|
`
|
||||||
|
result, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
workspace.Name,
|
||||||
|
workspace.Status,
|
||||||
|
workspace.K8sNamespace,
|
||||||
|
workspace.K8sSAName,
|
||||||
|
workspace.DefaultClusterID,
|
||||||
|
workspace.QuotaCPU,
|
||||||
|
workspace.QuotaMemory,
|
||||||
|
workspace.QuotaGPU,
|
||||||
|
workspace.QuotaGPUMem,
|
||||||
|
workspace.CreatedBy,
|
||||||
|
workspace.UpdatedAt,
|
||||||
|
workspace.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update workspace: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceRepository) List(ctx context.Context) ([]*entity.Workspace, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, name, status, k8s_namespace, k8s_sa_name, default_cluster_id, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, created_by, created_at, updated_at
|
||||||
|
FROM workspaces
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list workspaces: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
workspaces := make([]*entity.Workspace, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
workspace := &entity.Workspace{}
|
||||||
|
var createdBy, defaultClusterID, quotaCPU, quotaMemory, quotaGPU, quotaGPUMem sql.NullString
|
||||||
|
if err := rows.Scan(
|
||||||
|
&workspace.ID,
|
||||||
|
&workspace.Name,
|
||||||
|
&workspace.Status,
|
||||||
|
&workspace.K8sNamespace,
|
||||||
|
&workspace.K8sSAName,
|
||||||
|
&defaultClusterID,
|
||||||
|
"aCPU,
|
||||||
|
"aMemory,
|
||||||
|
"aGPU,
|
||||||
|
"aGPUMem,
|
||||||
|
&createdBy,
|
||||||
|
&workspace.CreatedAt,
|
||||||
|
&workspace.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan workspace: %w", err)
|
||||||
|
}
|
||||||
|
workspace.CreatedBy = createdBy.String
|
||||||
|
workspace.DefaultClusterID = defaultClusterID.String
|
||||||
|
workspace.QuotaCPU = quotaCPU.String
|
||||||
|
workspace.QuotaMemory = quotaMemory.String
|
||||||
|
workspace.QuotaGPU = quotaGPU.String
|
||||||
|
workspace.QuotaGPUMem = quotaGPUMem.String
|
||||||
|
workspaces = append(workspaces, workspace)
|
||||||
|
}
|
||||||
|
return workspaces, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceClusterBindingRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkspaceClusterBindingRepository(db *DB) repository.WorkspaceClusterBindingRepository {
|
||||||
|
return &WorkspaceClusterBindingRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceClusterBindingRepository) Upsert(ctx context.Context, binding *entity.WorkspaceClusterBinding) error {
|
||||||
|
if binding.ID == "" {
|
||||||
|
binding.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if binding.CreatedAt.IsZero() {
|
||||||
|
binding.CreatedAt = now
|
||||||
|
}
|
||||||
|
binding.UpdatedAt = now
|
||||||
|
query := `
|
||||||
|
INSERT INTO workspace_cluster_bindings
|
||||||
|
(id, workspace_id, cluster_id, namespace, service_account, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, status, created_at, updated_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
ON CONFLICT (workspace_id, cluster_id)
|
||||||
|
DO UPDATE SET namespace = EXCLUDED.namespace,
|
||||||
|
service_account = EXCLUDED.service_account,
|
||||||
|
quota_cpu = EXCLUDED.quota_cpu,
|
||||||
|
quota_memory = EXCLUDED.quota_memory,
|
||||||
|
quota_gpu = EXCLUDED.quota_gpu,
|
||||||
|
quota_gpu_memory = EXCLUDED.quota_gpu_memory,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
updated_at = EXCLUDED.updated_at
|
||||||
|
`
|
||||||
|
_, err := r.db.conn.ExecContext(ctx, query,
|
||||||
|
binding.ID,
|
||||||
|
binding.WorkspaceID,
|
||||||
|
binding.ClusterID,
|
||||||
|
binding.Namespace,
|
||||||
|
binding.ServiceAccount,
|
||||||
|
binding.QuotaCPU,
|
||||||
|
binding.QuotaMemory,
|
||||||
|
binding.QuotaGPU,
|
||||||
|
binding.QuotaGPUMem,
|
||||||
|
binding.Status,
|
||||||
|
binding.CreatedAt,
|
||||||
|
binding.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to upsert workspace cluster binding: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceClusterBindingRepository) Get(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, workspace_id, cluster_id, namespace, service_account, quota_cpu, quota_memory, quota_gpu, quota_gpu_memory, status, created_at, updated_at
|
||||||
|
FROM workspace_cluster_bindings
|
||||||
|
WHERE workspace_id = $1 AND cluster_id = $2
|
||||||
|
`
|
||||||
|
binding := &entity.WorkspaceClusterBinding{}
|
||||||
|
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, clusterID).Scan(
|
||||||
|
&binding.ID,
|
||||||
|
&binding.WorkspaceID,
|
||||||
|
&binding.ClusterID,
|
||||||
|
&binding.Namespace,
|
||||||
|
&binding.ServiceAccount,
|
||||||
|
&binding.QuotaCPU,
|
||||||
|
&binding.QuotaMemory,
|
||||||
|
&binding.QuotaGPU,
|
||||||
|
&binding.QuotaGPUMem,
|
||||||
|
&binding.Status,
|
||||||
|
&binding.CreatedAt,
|
||||||
|
&binding.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get workspace cluster binding: %w", err)
|
||||||
|
}
|
||||||
|
return binding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *WorkspaceClusterBindingRepository) Delete(ctx context.Context, workspaceID, clusterID string) error {
|
||||||
|
_, err := r.db.conn.ExecContext(ctx, `DELETE FROM workspace_cluster_bindings WHERE workspace_id = $1 AND cluster_id = $2`, workspaceID, clusterID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogRepository struct {
|
||||||
|
db *DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuditLogRepository(db *DB) repository.AuditLogRepository {
|
||||||
|
return &AuditLogRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditLogRepository) Create(ctx context.Context, logEntry *entity.AuditLog) error {
|
||||||
|
if logEntry.ID == "" {
|
||||||
|
logEntry.ID = uuid.New().String()
|
||||||
|
}
|
||||||
|
details, err := json.Marshal(logEntry.Details)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal audit details: %w", err)
|
||||||
|
}
|
||||||
|
if logEntry.CreatedAt.IsZero() {
|
||||||
|
logEntry.CreatedAt = time.Now()
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
INSERT INTO audit_logs (id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||||
|
`
|
||||||
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
|
logEntry.ID,
|
||||||
|
logEntry.WorkspaceID,
|
||||||
|
logEntry.UserID,
|
||||||
|
logEntry.Action,
|
||||||
|
logEntry.ResourceType,
|
||||||
|
logEntry.ResourceID,
|
||||||
|
logEntry.ResourceName,
|
||||||
|
string(details),
|
||||||
|
logEntry.IPAddress,
|
||||||
|
logEntry.UserAgent,
|
||||||
|
logEntry.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create audit log: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AuditLogRepository) ListByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE workspace_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to list audit logs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
result := make([]*entity.AuditLog, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
logEntry := &entity.AuditLog{}
|
||||||
|
var details []byte
|
||||||
|
if err := rows.Scan(
|
||||||
|
&logEntry.ID,
|
||||||
|
&logEntry.WorkspaceID,
|
||||||
|
&logEntry.UserID,
|
||||||
|
&logEntry.Action,
|
||||||
|
&logEntry.ResourceType,
|
||||||
|
&logEntry.ResourceID,
|
||||||
|
&logEntry.ResourceName,
|
||||||
|
&details,
|
||||||
|
&logEntry.IPAddress,
|
||||||
|
&logEntry.UserAgent,
|
||||||
|
&logEntry.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan audit log: %w", err)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(details, &logEntry.Details)
|
||||||
|
result = append(result, logEntry)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
@ -5,14 +5,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BootstrapConfig 预注入配置
|
// BootstrapConfig 预注入配置
|
||||||
type BootstrapConfig struct {
|
type BootstrapConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Users []UserSeed `json:"users"`
|
Users []UserSeed `json:"users"`
|
||||||
Registries []RegistrySeed `json:"registries"`
|
Registries []RegistrySeed `json:"registries"`
|
||||||
Clusters []ClusterSeed `json:"clusters"`
|
Clusters []ClusterSeed `json:"clusters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSeed 用户预注入数据
|
// UserSeed 用户预注入数据
|
||||||
@ -20,6 +23,7 @@ type UserSeed struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegistrySeed Registry 预注入数据
|
// RegistrySeed Registry 预注入数据
|
||||||
@ -45,11 +49,12 @@ type ClusterSeed struct {
|
|||||||
|
|
||||||
// LoadBootstrapConfig 加载预注入配置
|
// LoadBootstrapConfig 加载预注入配置
|
||||||
// 支持从文件或环境变量加载
|
// 支持从文件或环境变量加载
|
||||||
//
|
//
|
||||||
// 加载优先级:
|
// 加载优先级:
|
||||||
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
|
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
|
||||||
// 2. Mock 模式: 配置文件 config/bootstrap.json
|
// 2. 环境变量 BOOTSTRAP_* (root .env / container env)
|
||||||
// 3. 真实模式: GetDefaultBootstrapConfig() 中的真实数据
|
// 3. Mock 模式: 配置文件 config/bootstrap.json
|
||||||
|
// 4. 未提供任何 bootstrap 配置时禁用预注入
|
||||||
func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||||
// 1. 优先从环境变量加载
|
// 1. 优先从环境变量加载
|
||||||
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
|
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
|
||||||
@ -60,9 +65,13 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
|||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config, ok := loadBootstrapConfigFromEnv(); ok {
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 检查适配器模式
|
// 2. 检查适配器模式
|
||||||
adapterMode := os.Getenv("ADAPTER_MODE")
|
adapterMode := os.Getenv("ADAPTER_MODE")
|
||||||
|
|
||||||
// Mock 模式: 使用配置文件(假数据)
|
// Mock 模式: 使用配置文件(假数据)
|
||||||
if adapterMode == "mock" {
|
if adapterMode == "mock" {
|
||||||
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
|
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
|
||||||
@ -72,7 +81,7 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
|||||||
|
|
||||||
// 检查文件是否存在
|
// 检查文件是否存在
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||||
// 配置文件不存在,使用默认配置
|
// 配置文件不存在,不预注入任何数据
|
||||||
return GetDefaultBootstrapConfig(), nil
|
return GetDefaultBootstrapConfig(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,49 +98,142 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
|||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 真实模式 (mode 1, mode 2): 使用代码中的真实预注入数据
|
// 3. 真实模式: 未显式配置时不预注入任何数据
|
||||||
return GetDefaultBootstrapConfig(), nil
|
return GetDefaultBootstrapConfig(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultBootstrapConfig 获取默认的预注入配置(示例)
|
func loadBootstrapConfigFromEnv() (*BootstrapConfig, bool) {
|
||||||
func GetDefaultBootstrapConfig() *BootstrapConfig {
|
if !hasBootstrapEnv() {
|
||||||
return &BootstrapConfig{
|
return nil, false
|
||||||
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=",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config := &BootstrapConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Users: make([]UserSeed, 0, 1),
|
||||||
|
Registries: make([]RegistrySeed, 0, 1),
|
||||||
|
Clusters: make([]ClusterSeed, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_USER"))
|
||||||
|
adminPass := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_PASS"))
|
||||||
|
if adminUser != "" && adminPass != "" {
|
||||||
|
config.Users = append(config.Users, UserSeed{
|
||||||
|
Username: adminUser,
|
||||||
|
Password: adminPass,
|
||||||
|
Email: getEnv("BOOTSTRAP_ADMIN_EMAIL", adminUser+"@example.local"),
|
||||||
|
Role: "admin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if registryURL := os.Getenv("BOOTSTRAP_REGISTRY_URL"); registryURL != "" {
|
||||||
|
registryUser := getEnv("BOOTSTRAP_REGISTRY_ROBOT_USER", getEnv("BOOTSTRAP_REGISTRY_USER", ""))
|
||||||
|
registryPass := getEnv("BOOTSTRAP_REGISTRY_ROBOT_PASS", getEnv("BOOTSTRAP_REGISTRY_PASS", ""))
|
||||||
|
config.Registries = append(config.Registries, RegistrySeed{
|
||||||
|
Name: getEnv("BOOTSTRAP_REGISTRY_NAME", "harbor"),
|
||||||
|
URL: registryURL,
|
||||||
|
Description: getEnv("BOOTSTRAP_REGISTRY_DESC", ""),
|
||||||
|
Username: registryUser,
|
||||||
|
Password: registryPass,
|
||||||
|
Insecure: parseBoolEnv("BOOTSTRAP_REGISTRY_INSECURE", false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if parseBoolEnv("BOOTSTRAP_ENABLE_CLUSTERS", false) {
|
||||||
|
for _, clusterName := range discoverBootstrapClusters() {
|
||||||
|
prefix := "BOOTSTRAP_CLUSTER_" + normalizeEnvName(clusterName) + "_"
|
||||||
|
host := os.Getenv(prefix + "HOST")
|
||||||
|
if host == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Clusters = append(config.Clusters, ClusterSeed{
|
||||||
|
Name: strings.ToLower(clusterName),
|
||||||
|
Host: host,
|
||||||
|
Description: os.Getenv(prefix + "DESC"),
|
||||||
|
CAData: os.Getenv(prefix + "CA"),
|
||||||
|
CertData: os.Getenv(prefix + "CERT"),
|
||||||
|
KeyData: os.Getenv(prefix + "KEY"),
|
||||||
|
Token: os.Getenv(prefix + "TOKEN"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasBootstrapEnv() bool {
|
||||||
|
for _, env := range os.Environ() {
|
||||||
|
if strings.HasPrefix(env, "BOOTSTRAP_") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverBootstrapClusters() []string {
|
||||||
|
names := make(map[string]struct{})
|
||||||
|
|
||||||
|
if configured := os.Getenv("BOOTSTRAP_CLUSTERS"); configured != "" {
|
||||||
|
for _, name := range strings.Split(configured, ",") {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name != "" {
|
||||||
|
names[normalizeEnvName(name)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, env := range os.Environ() {
|
||||||
|
key, _, ok := strings.Cut(env, "=")
|
||||||
|
if !ok || !strings.HasPrefix(key, "BOOTSTRAP_CLUSTER_") || !strings.HasSuffix(key, "_HOST") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(strings.TrimPrefix(key, "BOOTSTRAP_CLUSTER_"), "_HOST")
|
||||||
|
if name != "" {
|
||||||
|
names[name] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(names))
|
||||||
|
for name := range names {
|
||||||
|
result = append(result, name)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEnvName(name string) string {
|
||||||
|
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
|
||||||
|
return strings.ToUpper(replacer.Replace(strings.TrimSpace(name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBoolEnv(key string, defaultValue bool) bool {
|
||||||
|
value := strings.TrimSpace(os.Getenv(key))
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseBool(value)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultBootstrapConfig 返回安全的空默认配置。
|
||||||
|
//
|
||||||
|
// 这里不能包含真实或示例账号密码、Registry 或集群凭据。预注入数据必须来自
|
||||||
|
// BOOTSTRAP_CONFIG_JSON、BOOTSTRAP_* 环境变量,或显式提供的 bootstrap 配置文件。
|
||||||
|
func GetDefaultBootstrapConfig() *BootstrapConfig {
|
||||||
|
return &BootstrapConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Users: []UserSeed{},
|
||||||
|
Registries: []RegistrySeed{},
|
||||||
|
Clusters: []ClusterSeed{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
103
backend/internal/bootstrap/config_test.go
Normal file
103
backend/internal/bootstrap/config_test.go
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDefaultBootstrapConfigIsEmptyAndDisabled(t *testing.T) {
|
||||||
|
config := GetDefaultBootstrapConfig()
|
||||||
|
if config.Enabled {
|
||||||
|
t.Fatal("default bootstrap config must be disabled")
|
||||||
|
}
|
||||||
|
if len(config.Users) != 0 || len(config.Registries) != 0 || len(config.Clusters) != 0 {
|
||||||
|
t.Fatalf("default bootstrap config must not include seeded data: %#v", config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadBootstrapConfigFromEnv(t *testing.T) {
|
||||||
|
t.Setenv("BOOTSTRAP_ADMIN_USER", "root")
|
||||||
|
t.Setenv("BOOTSTRAP_ADMIN_PASS", "secret")
|
||||||
|
t.Setenv("BOOTSTRAP_ADMIN_EMAIL", "root@example.com")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_NAME", "harbor")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_URL", "https://harbor.example.com")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_DESC", "test registry")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_USER", "robot")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_PASS", "robot-secret")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_ROBOT_USER", "robot$ocdp")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_ROBOT_PASS", "robot-token")
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_INSECURE", "true")
|
||||||
|
t.Setenv("BOOTSTRAP_ENABLE_CLUSTERS", "true")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTERS", "cluster1,gpu-prod")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_HOST", "https://cluster1.example.com:6443")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_DESC", "cluster one")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_CA", "ca-data")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_CERT", "cert-data")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_KEY", "key-data")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_GPU_PROD_HOST", "https://gpu.example.com:6443")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_GPU_PROD_TOKEN", "bearer-token")
|
||||||
|
|
||||||
|
config, ok := loadBootstrapConfigFromEnv()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected bootstrap config from environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Users) != 1 || config.Users[0].Username != "root" || config.Users[0].Password != "secret" {
|
||||||
|
t.Fatalf("unexpected users: %#v", config.Users)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Registries) != 1 {
|
||||||
|
t.Fatalf("expected one registry, got %d", len(config.Registries))
|
||||||
|
}
|
||||||
|
registry := config.Registries[0]
|
||||||
|
if registry.Name != "harbor" || registry.URL != "https://harbor.example.com" || !registry.Insecure {
|
||||||
|
t.Fatalf("unexpected registry: %#v", registry)
|
||||||
|
}
|
||||||
|
if registry.Username != "robot$ocdp" || registry.Password != "robot-token" {
|
||||||
|
t.Fatalf("expected robot registry credentials, got %#v", registry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(config.Clusters) != 2 {
|
||||||
|
t.Fatalf("expected two clusters, got %d: %#v", len(config.Clusters), config.Clusters)
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterByName := map[string]ClusterSeed{}
|
||||||
|
for _, cluster := range config.Clusters {
|
||||||
|
clusterByName[cluster.Name] = cluster
|
||||||
|
}
|
||||||
|
|
||||||
|
if clusterByName["cluster1"].Host != "https://cluster1.example.com:6443" {
|
||||||
|
t.Fatalf("unexpected cluster1: %#v", clusterByName["cluster1"])
|
||||||
|
}
|
||||||
|
if clusterByName["gpu_prod"].Token != "bearer-token" {
|
||||||
|
t.Fatalf("unexpected gpu_prod: %#v", clusterByName["gpu_prod"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapClustersRequireExplicitEnable(t *testing.T) {
|
||||||
|
t.Setenv("BOOTSTRAP_ADMIN_USER", "root")
|
||||||
|
t.Setenv("BOOTSTRAP_ADMIN_PASS", "secret")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTERS", "cluster1")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_HOST", "https://cluster1.example.com:6443")
|
||||||
|
t.Setenv("BOOTSTRAP_CLUSTER_CLUSTER1_TOKEN", "token")
|
||||||
|
|
||||||
|
config, ok := loadBootstrapConfigFromEnv()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected bootstrap config from environment")
|
||||||
|
}
|
||||||
|
if len(config.Clusters) != 0 {
|
||||||
|
t.Fatalf("bootstrap clusters must be disabled unless BOOTSTRAP_ENABLE_CLUSTERS=true, got %#v", config.Clusters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapEnvDoesNotCreateDefaultAdmin(t *testing.T) {
|
||||||
|
t.Setenv("BOOTSTRAP_REGISTRY_URL", "https://harbor.example.com")
|
||||||
|
|
||||||
|
config, ok := loadBootstrapConfigFromEnv()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected bootstrap config from environment")
|
||||||
|
}
|
||||||
|
if len(config.Users) != 0 {
|
||||||
|
t.Fatalf("expected no users without explicit admin credentials, got %#v", config.Users)
|
||||||
|
}
|
||||||
|
if len(config.Registries) != 1 {
|
||||||
|
t.Fatalf("expected one registry, got %d", len(config.Registries))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,6 +84,12 @@ func (s *Seeder) seedUsers(ctx context.Context) error {
|
|||||||
// 创建用户
|
// 创建用户
|
||||||
user := entity.NewUser(userSeed.Username, passwordHash, userSeed.Email)
|
user := entity.NewUser(userSeed.Username, passwordHash, userSeed.Email)
|
||||||
user.ID = uuid.New().String()
|
user.ID = uuid.New().String()
|
||||||
|
if userSeed.Role != "" {
|
||||||
|
user.Role = userSeed.Role
|
||||||
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.repos.UserRepo.Create(ctx, user); err != nil {
|
if err := s.repos.UserRepo.Create(ctx, user); err != nil {
|
||||||
log.Printf(" ✗ Failed to create user '%s': %v", userSeed.Username, err)
|
log.Printf(" ✗ Failed to create user '%s': %v", userSeed.Username, err)
|
||||||
@ -105,6 +111,7 @@ func (s *Seeder) seedRegistries(ctx context.Context) error {
|
|||||||
|
|
||||||
log.Printf(" ↳ Seeding %d registry(ies)...", len(s.config.Registries))
|
log.Printf(" ↳ Seeding %d registry(ies)...", len(s.config.Registries))
|
||||||
|
|
||||||
|
ownerID := s.bootstrapOwnerID(ctx)
|
||||||
for _, registrySeed := range s.config.Registries {
|
for _, registrySeed := range s.config.Registries {
|
||||||
// 检查 Registry 是否已存在
|
// 检查 Registry 是否已存在
|
||||||
existingRegistry, _ := s.repos.RegistryRepo.GetByName(ctx, registrySeed.Name)
|
existingRegistry, _ := s.repos.RegistryRepo.GetByName(ctx, registrySeed.Name)
|
||||||
@ -117,6 +124,9 @@ func (s *Seeder) seedRegistries(ctx context.Context) error {
|
|||||||
registry := &entity.Registry{
|
registry := &entity.Registry{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Name: registrySeed.Name,
|
Name: registrySeed.Name,
|
||||||
|
WorkspaceID: entity.DefaultWorkspaceID,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Visibility: "global_shared",
|
||||||
URL: registrySeed.URL,
|
URL: registrySeed.URL,
|
||||||
Description: registrySeed.Description,
|
Description: registrySeed.Description,
|
||||||
Username: registrySeed.Username,
|
Username: registrySeed.Username,
|
||||||
@ -146,6 +156,7 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
|
|||||||
|
|
||||||
log.Printf(" ↳ Seeding %d cluster(s)...", len(s.config.Clusters))
|
log.Printf(" ↳ Seeding %d cluster(s)...", len(s.config.Clusters))
|
||||||
|
|
||||||
|
ownerID := s.bootstrapOwnerID(ctx)
|
||||||
for _, clusterSeed := range s.config.Clusters {
|
for _, clusterSeed := range s.config.Clusters {
|
||||||
// 检查 Cluster 是否已存在
|
// 检查 Cluster 是否已存在
|
||||||
existingCluster, _ := s.repos.ClusterRepo.GetByName(ctx, clusterSeed.Name)
|
existingCluster, _ := s.repos.ClusterRepo.GetByName(ctx, clusterSeed.Name)
|
||||||
@ -158,6 +169,9 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
|
|||||||
cluster := &entity.Cluster{
|
cluster := &entity.Cluster{
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
Name: clusterSeed.Name,
|
Name: clusterSeed.Name,
|
||||||
|
WorkspaceID: entity.DefaultWorkspaceID,
|
||||||
|
OwnerID: ownerID,
|
||||||
|
Visibility: "global_shared",
|
||||||
Host: clusterSeed.Host,
|
Host: clusterSeed.Host,
|
||||||
Description: clusterSeed.Description,
|
Description: clusterSeed.Description,
|
||||||
CAData: clusterSeed.CAData,
|
CAData: clusterSeed.CAData,
|
||||||
@ -179,3 +193,22 @@ func (s *Seeder) seedClusters(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Seeder) bootstrapOwnerID(ctx context.Context) string {
|
||||||
|
for _, userSeed := range s.config.Users {
|
||||||
|
if userSeed.Role == "admin" {
|
||||||
|
if user, err := s.repos.UserRepo.GetByUsername(ctx, userSeed.Username); err == nil && user != nil {
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users, err := s.repos.UserRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
if user.Role == "admin" {
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ArtifactType Artifact 类型
|
// ArtifactType Artifact 类型
|
||||||
@ -16,16 +16,16 @@ const (
|
|||||||
|
|
||||||
// Artifact OCI Artifact 领域实体
|
// Artifact OCI Artifact 领域实体
|
||||||
type Artifact struct {
|
type Artifact struct {
|
||||||
RegistryID string
|
RegistryID string
|
||||||
Repository string
|
Repository string
|
||||||
Tag string
|
Tag string
|
||||||
Digest string
|
Digest string
|
||||||
Type ArtifactType
|
Type ArtifactType
|
||||||
Size int64
|
Size int64
|
||||||
MediaType string
|
MediaType string
|
||||||
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
||||||
Annotations map[string]string
|
Annotations map[string]string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Repository 仓库信息
|
// Repository 仓库信息
|
||||||
@ -50,34 +50,34 @@ func NewArtifact(registryID, repository, tag, digest string) *Artifact {
|
|||||||
// SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other)
|
// SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other)
|
||||||
// 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断
|
// 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断
|
||||||
func (a *Artifact) SetType(mediaType string) {
|
func (a *Artifact) SetType(mediaType string) {
|
||||||
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
||||||
|
|
||||||
containsAny := func(target string, keywords ...string) bool {
|
containsAny := func(target string, keywords ...string) bool {
|
||||||
for _, keyword := range keywords {
|
for _, keyword := range keywords {
|
||||||
if keyword != "" && strings.Contains(target, keyword) {
|
if keyword != "" && strings.Contains(target, keyword) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case lowerMediaType == "":
|
case lowerMediaType == "":
|
||||||
a.Type = ArtifactTypeOther
|
a.Type = ArtifactTypeOther
|
||||||
case containsAny(lowerMediaType,
|
case containsAny(lowerMediaType,
|
||||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||||
):
|
):
|
||||||
a.Type = ArtifactTypeChart
|
a.Type = ArtifactTypeChart
|
||||||
case containsAny(lowerMediaType,
|
case containsAny(lowerMediaType,
|
||||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||||
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
||||||
):
|
):
|
||||||
a.Type = ArtifactTypeImage
|
a.Type = ArtifactTypeImage
|
||||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
||||||
a.Type = ArtifactTypeImage
|
a.Type = ArtifactTypeImage
|
||||||
default:
|
default:
|
||||||
a.Type = ArtifactTypeOther
|
a.Type = ArtifactTypeOther
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetermineType 智能判断 Artifact 类型(综合多种信息)
|
// DetermineType 智能判断 Artifact 类型(综合多种信息)
|
||||||
@ -87,85 +87,84 @@ func (a *Artifact) SetType(mediaType string) {
|
|||||||
// 3. Repository 名称 - charts/ 前缀暗示
|
// 3. Repository 名称 - charts/ 前缀暗示
|
||||||
// 4. MediaType - 兜底判断
|
// 4. MediaType - 兜底判断
|
||||||
func (a *Artifact) DetermineType() {
|
func (a *Artifact) DetermineType() {
|
||||||
containsAny := func(target string, keywords ...string) bool {
|
containsAny := func(target string, keywords ...string) bool {
|
||||||
for _, keyword := range keywords {
|
for _, keyword := range keywords {
|
||||||
if keyword != "" && strings.Contains(target, keyword) {
|
if keyword != "" && strings.Contains(target, keyword) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 优先检查 ConfigType(最准确的判断方式)
|
// 1. 优先检查 ConfigType(最准确的判断方式)
|
||||||
if a.ConfigType != "" {
|
if a.ConfigType != "" {
|
||||||
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
||||||
|
|
||||||
// Helm Chart 的 config.mediaType
|
// Helm Chart 的 config.mediaType
|
||||||
if containsAny(lowerConfigType,
|
if containsAny(lowerConfigType,
|
||||||
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
||||||
) {
|
) {
|
||||||
a.Type = ArtifactTypeChart
|
a.Type = ArtifactTypeChart
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker/OCI Image 的 config.mediaType
|
// Docker/OCI Image 的 config.mediaType
|
||||||
if containsAny(lowerConfigType,
|
if containsAny(lowerConfigType,
|
||||||
"docker.container.image", "oci.image.config",
|
"docker.container.image", "oci.image.config",
|
||||||
) {
|
) {
|
||||||
a.Type = ArtifactTypeImage
|
a.Type = ArtifactTypeImage
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查 Annotations
|
// 2. 检查 Annotations
|
||||||
for key, value := range a.Annotations {
|
for key, value := range a.Annotations {
|
||||||
lowerKey := strings.ToLower(key)
|
lowerKey := strings.ToLower(key)
|
||||||
lowerValue := strings.ToLower(value)
|
lowerValue := strings.ToLower(value)
|
||||||
|
|
||||||
if containsAny(lowerKey, "helm", "chart") ||
|
if containsAny(lowerKey, "helm", "chart") ||
|
||||||
containsAny(lowerValue, "helm", "chart") {
|
containsAny(lowerValue, "helm", "chart") {
|
||||||
a.Type = ArtifactTypeChart
|
a.Type = ArtifactTypeChart
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查 Repository 名称(辅助判断)
|
// 3. 检查 Repository 名称(辅助判断)
|
||||||
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
||||||
// charts/ 开头的仓库很可能是 Helm Chart
|
// charts/ 开头的仓库很可能是 Helm Chart
|
||||||
// 但需要结合 MediaType 进一步确认
|
// 但需要结合 MediaType 进一步确认
|
||||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||||
|
|
||||||
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
||||||
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
||||||
strings.Contains(lowerMediaType, "vnd.oci") {
|
strings.Contains(lowerMediaType, "vnd.oci") {
|
||||||
a.Type = ArtifactTypeChart
|
a.Type = ArtifactTypeChart
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
||||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case lowerMediaType == "":
|
case lowerMediaType == "":
|
||||||
a.Type = ArtifactTypeOther
|
a.Type = ArtifactTypeOther
|
||||||
case containsAny(lowerMediaType,
|
case containsAny(lowerMediaType,
|
||||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||||
):
|
):
|
||||||
a.Type = ArtifactTypeChart
|
a.Type = ArtifactTypeChart
|
||||||
case containsAny(lowerMediaType,
|
case containsAny(lowerMediaType,
|
||||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||||
):
|
):
|
||||||
a.Type = ArtifactTypeImage
|
a.Type = ArtifactTypeImage
|
||||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
||||||
a.Type = ArtifactTypeImage
|
a.Type = ArtifactTypeImage
|
||||||
default:
|
default:
|
||||||
a.Type = ArtifactTypeOther
|
a.Type = ArtifactTypeOther
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsChart 判断是否为 Helm Chart
|
// IsChart 判断是否为 Helm Chart
|
||||||
func (a *Artifact) IsChart() bool {
|
func (a *Artifact) IsChart() bool {
|
||||||
return a.Type == ArtifactTypeChart
|
return a.Type == ArtifactTypeChart
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,26 +6,31 @@ import (
|
|||||||
|
|
||||||
// Cluster Kubernetes 集群领域实体
|
// Cluster Kubernetes 集群领域实体
|
||||||
type Cluster struct {
|
type Cluster struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
WorkspaceID string
|
||||||
Host string // Kubernetes API Server URL
|
OwnerID string
|
||||||
CAData string // Base64 encoded CA certificate
|
Visibility string
|
||||||
CertData string // Base64 encoded client certificate
|
Name string
|
||||||
KeyData string // Base64 encoded client key
|
Host string // Kubernetes API Server URL
|
||||||
Token string // Bearer token (alternative to cert auth)
|
CAData string // Base64 encoded CA certificate
|
||||||
Description string
|
CertData string // Base64 encoded client certificate
|
||||||
CreatedAt time.Time
|
KeyData string // Base64 encoded client key
|
||||||
UpdatedAt time.Time
|
Token string // Bearer token (alternative to cert auth)
|
||||||
|
Description string
|
||||||
|
DefaultNamespace string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCluster 创建新集群
|
// NewCluster 创建新集群
|
||||||
func NewCluster(name, host string) *Cluster {
|
func NewCluster(name, host string) *Cluster {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &Cluster{
|
return &Cluster{
|
||||||
Name: name,
|
Name: name,
|
||||||
Host: host,
|
Host: host,
|
||||||
CreatedAt: now,
|
Visibility: "private",
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +68,9 @@ func (c *Cluster) Validate() error {
|
|||||||
if c.Host == "" {
|
if c.Host == "" {
|
||||||
return ErrInvalidClusterHost
|
return ErrInvalidClusterHost
|
||||||
}
|
}
|
||||||
|
if c.Visibility == "" {
|
||||||
|
c.Visibility = "private"
|
||||||
|
}
|
||||||
// 必须有认证方式:证书或 Token
|
// 必须有认证方式:证书或 Token
|
||||||
if (c.CertData == "" || c.KeyData == "") && c.Token == "" {
|
if (c.CertData == "" || c.KeyData == "") && c.Token == "" {
|
||||||
return ErrInvalidClusterAuth
|
return ErrInvalidClusterAuth
|
||||||
@ -100,4 +108,3 @@ users:
|
|||||||
|
|
||||||
return kubeconfig
|
return kubeconfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,11 +5,15 @@ import "errors"
|
|||||||
// 领域错误定义
|
// 领域错误定义
|
||||||
var (
|
var (
|
||||||
// User errors
|
// User errors
|
||||||
ErrInvalidUsername = errors.New("invalid username")
|
ErrInvalidUsername = errors.New("invalid username")
|
||||||
ErrInvalidPassword = errors.New("invalid password")
|
ErrInvalidPassword = errors.New("invalid password")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
ErrUserExists = errors.New("user already exists")
|
ErrUserExists = errors.New("user already exists")
|
||||||
ErrTokenRevoked = errors.New("token has been revoked")
|
ErrTokenRevoked = errors.New("token has been revoked")
|
||||||
|
ErrUnauthorized = errors.New("authentication required")
|
||||||
|
ErrForbidden = errors.New("permission denied")
|
||||||
|
ErrUserInactive = errors.New("user is inactive")
|
||||||
|
ErrWorkspaceSuspended = errors.New("workspace is suspended")
|
||||||
|
|
||||||
// Cluster errors
|
// Cluster errors
|
||||||
ErrInvalidClusterName = errors.New("invalid cluster name")
|
ErrInvalidClusterName = errors.New("invalid cluster name")
|
||||||
@ -37,4 +41,8 @@ var (
|
|||||||
ErrArtifactNotFound = errors.New("artifact not found")
|
ErrArtifactNotFound = errors.New("artifact not found")
|
||||||
ErrRepositoryNotFound = errors.New("repository not found")
|
ErrRepositoryNotFound = errors.New("repository not found")
|
||||||
ErrValuesSchemaNotFound = errors.New("values schema not found")
|
ErrValuesSchemaNotFound = errors.New("values schema not found")
|
||||||
|
|
||||||
|
// Workspace errors
|
||||||
|
ErrWorkspaceNotFound = errors.New("workspace not found")
|
||||||
|
ErrWorkspaceExists = errors.New("workspace already exists")
|
||||||
)
|
)
|
||||||
|
|||||||
@ -34,6 +34,8 @@ const (
|
|||||||
// Instance Helm 应用实例领域实体
|
// Instance Helm 应用实例领域实体
|
||||||
type Instance struct {
|
type Instance struct {
|
||||||
ID string
|
ID string
|
||||||
|
WorkspaceID string
|
||||||
|
OwnerID string
|
||||||
ClusterID string
|
ClusterID string
|
||||||
Name string // Helm Release Name
|
Name string // Helm Release Name
|
||||||
Namespace string
|
Namespace string
|
||||||
@ -51,6 +53,7 @@ type Instance struct {
|
|||||||
Revision int // Helm Release Revision
|
Revision int // Helm Release Revision
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
Replicas int // Running K8s replicas (enriched, not persisted)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInstance 创建新实例
|
// NewInstance 创建新实例
|
||||||
|
|||||||
70
backend/internal/domain/entity/instance_diagnostics.go
Normal file
70
backend/internal/domain/entity/instance_diagnostics.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type InstanceDiagnostics struct {
|
||||||
|
InstanceName string
|
||||||
|
Namespace string
|
||||||
|
Pods []InstancePodDiagnostics
|
||||||
|
Services []InstanceServiceDiagnostics
|
||||||
|
Events []InstanceEventDiagnostics
|
||||||
|
Logs []InstancePodLog
|
||||||
|
CollectedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstancePodDiagnostics struct {
|
||||||
|
Name string
|
||||||
|
Namespace string
|
||||||
|
Phase string
|
||||||
|
NodeName string
|
||||||
|
PodIP string
|
||||||
|
HostIP string
|
||||||
|
RestartCount int32
|
||||||
|
Containers []InstanceContainerDiagnostics
|
||||||
|
Conditions []InstanceConditionDiagnostics
|
||||||
|
CreationTimestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceContainerDiagnostics struct {
|
||||||
|
Name string
|
||||||
|
Image string
|
||||||
|
Ready bool
|
||||||
|
RestartCount int32
|
||||||
|
State string
|
||||||
|
Reason string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceConditionDiagnostics struct {
|
||||||
|
Type string
|
||||||
|
Status string
|
||||||
|
Reason string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceServiceDiagnostics struct {
|
||||||
|
Name string
|
||||||
|
Namespace string
|
||||||
|
Type string
|
||||||
|
ClusterIP string
|
||||||
|
Ports []InstanceEntryPort
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceEventDiagnostics struct {
|
||||||
|
Type string
|
||||||
|
Reason string
|
||||||
|
Message string
|
||||||
|
InvolvedKind string
|
||||||
|
InvolvedName string
|
||||||
|
Count int32
|
||||||
|
FirstTimestamp time.Time
|
||||||
|
LastTimestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstancePodLog struct {
|
||||||
|
Pod string
|
||||||
|
Container string
|
||||||
|
TailLines int64
|
||||||
|
Log string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
@ -4,70 +4,70 @@ import "time"
|
|||||||
|
|
||||||
// ClusterMetrics 集群监控指标
|
// ClusterMetrics 集群监控指标
|
||||||
type ClusterMetrics struct {
|
type ClusterMetrics struct {
|
||||||
ClusterID string `json:"cluster_id"`
|
ClusterID string `json:"cluster_id"`
|
||||||
ClusterName string `json:"cluster_name"`
|
ClusterName string `json:"cluster_name"`
|
||||||
Status string `json:"status"` // healthy, warning, error, unknown
|
Status string `json:"status"` // healthy, warning, error, unknown
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
NodeCount int `json:"node_count"`
|
NodeCount int `json:"node_count"`
|
||||||
PodCount int `json:"pod_count"`
|
PodCount int `json:"pod_count"`
|
||||||
LastCheck time.Time `json:"last_check"`
|
LastCheck time.Time `json:"last_check"`
|
||||||
|
|
||||||
// 集群级别资源汇总
|
// 集群级别资源汇总
|
||||||
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
||||||
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
||||||
TotalGPU int `json:"total_gpu"` // GPU 总数
|
TotalGPU int `json:"total_gpu"` // GPU 总数
|
||||||
|
|
||||||
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
||||||
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
||||||
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
||||||
|
|
||||||
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
||||||
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
||||||
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
||||||
|
|
||||||
// 单机资源最大值
|
// 单机资源最大值
|
||||||
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
||||||
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
||||||
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
||||||
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
||||||
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
||||||
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
||||||
|
|
||||||
// 节点列表(简化信息)
|
// 节点列表(简化信息)
|
||||||
Nodes []NodeMetrics `json:"nodes,omitempty"`
|
Nodes []NodeMetrics `json:"nodes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeMetrics 节点监控指标
|
// NodeMetrics 节点监控指标
|
||||||
type NodeMetrics struct {
|
type NodeMetrics struct {
|
||||||
NodeName string `json:"node_name"`
|
NodeName string `json:"node_name"`
|
||||||
Status string `json:"status"` // Ready, NotReady
|
Status string `json:"status"` // Ready, NotReady
|
||||||
Role string `json:"role"` // control-plane, worker
|
Role string `json:"role"` // control-plane, worker
|
||||||
Age string `json:"age"`
|
Age string `json:"age"`
|
||||||
PodCount int `json:"pod_count"`
|
PodCount int `json:"pod_count"`
|
||||||
|
|
||||||
// CPU 资源
|
// CPU 资源
|
||||||
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
||||||
CPUAllocatable string `json:"cpu_allocatable"`
|
CPUAllocatable string `json:"cpu_allocatable"`
|
||||||
CPUUsage string `json:"cpu_usage"`
|
CPUUsage string `json:"cpu_usage"`
|
||||||
CPUPercent float64 `json:"cpu_percent"`
|
CPUPercent float64 `json:"cpu_percent"`
|
||||||
|
|
||||||
// 内存资源
|
// 内存资源
|
||||||
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
||||||
MemoryAllocatable string `json:"memory_allocatable"`
|
MemoryAllocatable string `json:"memory_allocatable"`
|
||||||
MemoryUsage string `json:"memory_usage"`
|
MemoryUsage string `json:"memory_usage"`
|
||||||
MemoryPercent float64 `json:"memory_percent"`
|
MemoryPercent float64 `json:"memory_percent"`
|
||||||
|
|
||||||
// GPU 资源(如果有)
|
// GPU 资源(如果有)
|
||||||
GPUCapacity int `json:"gpu_capacity"` // GPU 总数
|
GPUCapacity int `json:"gpu_capacity"` // GPU 总数
|
||||||
GPUUsage int `json:"gpu_usage"` // 已使用的 GPU
|
GPUUsage int `json:"gpu_usage"` // 已使用的 GPU
|
||||||
GPUPercent float64 `json:"gpu_percent"`
|
GPUPercent float64 `json:"gpu_percent"`
|
||||||
GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4"
|
GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4"
|
||||||
|
|
||||||
// 其他信息
|
// 其他信息
|
||||||
OSImage string `json:"os_image,omitempty"`
|
OSImage string `json:"os_image,omitempty"`
|
||||||
KernelVersion string `json:"kernel_version,omitempty"`
|
KernelVersion string `json:"kernel_version,omitempty"`
|
||||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||||
KubeletVersion string `json:"kubelet_version,omitempty"`
|
KubeletVersion string `json:"kubelet_version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MonitoringSummary 监控汇总
|
// MonitoringSummary 监控汇总
|
||||||
@ -80,4 +80,3 @@ type MonitoringSummary struct {
|
|||||||
TotalPods int `json:"total_pods"`
|
TotalPods int `json:"total_pods"`
|
||||||
LastUpdate time.Time `json:"last_update"`
|
LastUpdate time.Time `json:"last_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import (
|
|||||||
// Registry OCI Registry 领域实体
|
// Registry OCI Registry 领域实体
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
ID string
|
ID string
|
||||||
|
WorkspaceID string
|
||||||
|
OwnerID string
|
||||||
|
Visibility string
|
||||||
Name string
|
Name string
|
||||||
URL string
|
URL string
|
||||||
Description string
|
Description string
|
||||||
@ -21,10 +24,11 @@ type Registry struct {
|
|||||||
func NewRegistry(name, url string) *Registry {
|
func NewRegistry(name, url string) *Registry {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &Registry{
|
return &Registry{
|
||||||
Name: name,
|
Name: name,
|
||||||
URL: url,
|
URL: url,
|
||||||
CreatedAt: now,
|
Visibility: "private",
|
||||||
UpdatedAt: now,
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +59,8 @@ func (r *Registry) Validate() error {
|
|||||||
if r.URL == "" {
|
if r.URL == "" {
|
||||||
return ErrInvalidRegistryURL
|
return ErrInvalidRegistryURL
|
||||||
}
|
}
|
||||||
|
if r.Visibility == "" {
|
||||||
|
r.Visibility = "private"
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
backend/internal/domain/entity/tenant_binding.go
Normal file
123
backend/internal/domain/entity/tenant_binding.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultTenantServiceAccountName = "tenant-admin"
|
||||||
|
DefaultTenantRoleBindingName = "tenant-admin"
|
||||||
|
DefaultTenantClusterRoleName = "admin"
|
||||||
|
DefaultTenantResourceQuotaName = "tenant-quota"
|
||||||
|
MaxTenantKubeconfigTTL = 2 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidTenantNamespace = errors.New("invalid tenant namespace")
|
||||||
|
ErrInvalidTenantServiceAccount = errors.New("invalid tenant service account")
|
||||||
|
ErrInvalidTenantRoleBinding = errors.New("invalid tenant role binding")
|
||||||
|
ErrInvalidTenantClusterRole = errors.New("invalid tenant cluster role")
|
||||||
|
ErrInvalidTenantResourceQuota = errors.New("invalid tenant resource quota")
|
||||||
|
ErrInvalidTenantKubeconfigToken = errors.New("invalid tenant kubeconfig token")
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantBinding describes the Kubernetes resources that grant a workspace access
|
||||||
|
// to one tenant namespace. It intentionally excludes credential material.
|
||||||
|
type TenantBinding struct {
|
||||||
|
Namespace string
|
||||||
|
ServiceAccountName string
|
||||||
|
RoleBindingName string
|
||||||
|
ClusterRoleName string
|
||||||
|
ResourceQuotaName string
|
||||||
|
Labels map[string]string
|
||||||
|
Annotations map[string]string
|
||||||
|
ResourceQuotaHard corev1.ResourceList
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantKubeconfig contains a short-lived kubeconfig and its expiration time.
|
||||||
|
// Callers must treat Kubeconfig as secret material and must not persist or log it.
|
||||||
|
type TenantKubeconfig struct {
|
||||||
|
Kubeconfig string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTenantBinding returns a tenant binding with production-safe default object names.
|
||||||
|
func NewTenantBinding(namespace string) TenantBinding {
|
||||||
|
return TenantBinding{
|
||||||
|
Namespace: namespace,
|
||||||
|
ServiceAccountName: DefaultTenantServiceAccountName,
|
||||||
|
RoleBindingName: DefaultTenantRoleBindingName,
|
||||||
|
ClusterRoleName: DefaultTenantClusterRoleName,
|
||||||
|
ResourceQuotaName: DefaultTenantResourceQuotaName,
|
||||||
|
Labels: map[string]string{
|
||||||
|
"ocdp.io/managed-by": "ocdp",
|
||||||
|
"ocdp.io/tenant": namespace,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDefaults fills optional names while preserving explicit caller choices.
|
||||||
|
func (b TenantBinding) WithDefaults() TenantBinding {
|
||||||
|
if b.ServiceAccountName == "" {
|
||||||
|
b.ServiceAccountName = DefaultTenantServiceAccountName
|
||||||
|
}
|
||||||
|
if b.RoleBindingName == "" {
|
||||||
|
b.RoleBindingName = DefaultTenantRoleBindingName
|
||||||
|
}
|
||||||
|
if b.ClusterRoleName == "" {
|
||||||
|
b.ClusterRoleName = DefaultTenantClusterRoleName
|
||||||
|
}
|
||||||
|
if b.ResourceQuotaName == "" {
|
||||||
|
b.ResourceQuotaName = DefaultTenantResourceQuotaName
|
||||||
|
}
|
||||||
|
if b.Labels == nil {
|
||||||
|
b.Labels = map[string]string{}
|
||||||
|
}
|
||||||
|
if b.Labels["ocdp.io/managed-by"] == "" {
|
||||||
|
b.Labels["ocdp.io/managed-by"] = "ocdp"
|
||||||
|
}
|
||||||
|
if b.Namespace != "" && b.Labels["ocdp.io/tenant"] == "" {
|
||||||
|
b.Labels["ocdp.io/tenant"] = b.Namespace
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the object names required to provision a tenant namespace.
|
||||||
|
func (b TenantBinding) Validate() error {
|
||||||
|
b = b.WithDefaults()
|
||||||
|
if strings.TrimSpace(b.Namespace) == "" || len(validation.IsDNS1123Label(b.Namespace)) > 0 {
|
||||||
|
return ErrInvalidTenantNamespace
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.ServiceAccountName) == "" || len(validation.IsDNS1123Subdomain(b.ServiceAccountName)) > 0 {
|
||||||
|
return ErrInvalidTenantServiceAccount
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.RoleBindingName) == "" || len(validation.IsDNS1123Subdomain(b.RoleBindingName)) > 0 {
|
||||||
|
return ErrInvalidTenantRoleBinding
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.ClusterRoleName) == "" || len(validation.IsDNS1123Subdomain(b.ClusterRoleName)) > 0 {
|
||||||
|
return ErrInvalidTenantClusterRole
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(b.ResourceQuotaName) == "" || len(validation.IsDNS1123Subdomain(b.ResourceQuotaName)) > 0 {
|
||||||
|
return ErrInvalidTenantResourceQuota
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantTokenTTL caps requested kubeconfig lifetimes at MaxTenantKubeconfigTTL.
|
||||||
|
func TenantTokenTTL(requested time.Duration) time.Duration {
|
||||||
|
if requested <= 0 || requested > MaxTenantKubeconfigTTL {
|
||||||
|
return MaxTenantKubeconfigTTL
|
||||||
|
}
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b TenantBinding) String() string {
|
||||||
|
b = b.WithDefaults()
|
||||||
|
return fmt.Sprintf("tenant namespace %q serviceAccount %q roleBinding %q", b.Namespace, b.ServiceAccountName, b.RoleBindingName)
|
||||||
|
}
|
||||||
38
backend/internal/domain/entity/tenant_binding_test.go
Normal file
38
backend/internal/domain/entity/tenant_binding_test.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTenantTokenTTLCapsAtTwoHours(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
requested time.Duration
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{name: "uses default for zero", requested: 0, want: MaxTenantKubeconfigTTL},
|
||||||
|
{name: "keeps shorter ttl", requested: 30 * time.Minute, want: 30 * time.Minute},
|
||||||
|
{name: "caps longer ttl", requested: 24 * time.Hour, want: MaxTenantKubeconfigTTL},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
if got := TenantTokenTTL(tc.requested); got != tc.want {
|
||||||
|
t.Fatalf("%s: expected %s, got %s", tc.name, tc.want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTenantBindingWithDefaults(t *testing.T) {
|
||||||
|
binding := NewTenantBinding("tenant-a").WithDefaults()
|
||||||
|
|
||||||
|
if err := binding.Validate(); err != nil {
|
||||||
|
t.Fatalf("expected valid default binding: %v", err)
|
||||||
|
}
|
||||||
|
if binding.ServiceAccountName != DefaultTenantServiceAccountName {
|
||||||
|
t.Fatalf("expected default service account %q, got %q", DefaultTenantServiceAccountName, binding.ServiceAccountName)
|
||||||
|
}
|
||||||
|
if binding.Labels["ocdp.io/tenant"] != "tenant-a" {
|
||||||
|
t.Fatalf("expected tenant label, got %#v", binding.Labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,13 +6,17 @@ import (
|
|||||||
|
|
||||||
// User 用户领域实体
|
// User 用户领域实体
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
Username string
|
Username string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
Email string
|
Email string
|
||||||
RevokedAfter time.Time // 全局 Token 撤销时间
|
Role string
|
||||||
CreatedAt time.Time
|
WorkspaceID string
|
||||||
UpdatedAt time.Time
|
IsActive bool
|
||||||
|
MustChangePassword bool
|
||||||
|
RevokedAfter time.Time // 全局 Token 撤销时间
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUser 创建新用户
|
// NewUser 创建新用户
|
||||||
@ -22,6 +26,9 @@ func NewUser(username, passwordHash, email string) *User {
|
|||||||
Username: username,
|
Username: username,
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: passwordHash,
|
||||||
Email: email,
|
Email: email,
|
||||||
|
Role: "user",
|
||||||
|
WorkspaceID: DefaultWorkspaceID,
|
||||||
|
IsActive: true,
|
||||||
RevokedAfter: time.Unix(0, 0), // 初始值:1970-01-01
|
RevokedAfter: time.Unix(0, 0), // 初始值:1970-01-01
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
@ -49,6 +56,11 @@ func (u *User) Validate() error {
|
|||||||
if u.PasswordHash == "" {
|
if u.PasswordHash == "" {
|
||||||
return ErrInvalidPassword
|
return ErrInvalidPassword
|
||||||
}
|
}
|
||||||
|
if u.Role == "" {
|
||||||
|
u.Role = "user"
|
||||||
|
}
|
||||||
|
if u.WorkspaceID == "" && u.Role != "admin" {
|
||||||
|
u.WorkspaceID = DefaultWorkspaceID
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
150
backend/internal/domain/entity/workspace.go
Normal file
150
backend/internal/domain/entity/workspace.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package entity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultWorkspaceID = "00000000-0000-0000-0000-000000000010"
|
||||||
|
DefaultWorkspaceName = "default"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
WorkspaceActive WorkspaceStatus = "active"
|
||||||
|
WorkspaceSuspended WorkspaceStatus = "suspended"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Workspace struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Status WorkspaceStatus
|
||||||
|
K8sNamespace string
|
||||||
|
K8sSAName string
|
||||||
|
DefaultClusterID string
|
||||||
|
QuotaCPU string
|
||||||
|
QuotaMemory string
|
||||||
|
QuotaGPU string
|
||||||
|
QuotaGPUMem string
|
||||||
|
CreatedBy string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkspace(name, createdBy string) *Workspace {
|
||||||
|
now := time.Now()
|
||||||
|
return &Workspace{
|
||||||
|
Name: name,
|
||||||
|
Status: WorkspaceActive,
|
||||||
|
K8sNamespace: NamespaceForWorkspace(name),
|
||||||
|
K8sSAName: ServiceAccountForWorkspace(name),
|
||||||
|
CreatedBy: createdBy,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NamespaceForWorkspace(name string) string {
|
||||||
|
if name == "" {
|
||||||
|
name = DefaultWorkspaceName
|
||||||
|
}
|
||||||
|
return prefixedDNSLabel("ocdp-ws-", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NamespaceForUser(username string) string {
|
||||||
|
if username == "" {
|
||||||
|
username = "user"
|
||||||
|
}
|
||||||
|
return prefixedDNSLabel("ocdp-u-", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServiceAccountForWorkspace(name string) string {
|
||||||
|
if name == "" {
|
||||||
|
name = DefaultWorkspaceName
|
||||||
|
}
|
||||||
|
return prefixedDNSLabel("ocdp-ws-", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ServiceAccountForNamespace(namespace string) string {
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = DefaultWorkspaceName
|
||||||
|
}
|
||||||
|
return prefixedDNSLabel("ocdp-sa-", namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefixedDNSLabel(prefix, value string) string {
|
||||||
|
label := normalizeDNSLabel(value)
|
||||||
|
maxLabelLen := 63 - len(prefix)
|
||||||
|
if maxLabelLen < 1 {
|
||||||
|
maxLabelLen = 1
|
||||||
|
}
|
||||||
|
if len(label) > maxLabelLen {
|
||||||
|
label = strings.Trim(label[:maxLabelLen], "-")
|
||||||
|
}
|
||||||
|
if label == "" {
|
||||||
|
label = DefaultWorkspaceName
|
||||||
|
if len(label) > maxLabelLen {
|
||||||
|
label = label[:maxLabelLen]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prefix + label
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDNSLabel(value string) string {
|
||||||
|
out := make([]rune, 0, len(value))
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range value {
|
||||||
|
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
r = r + ('a' - 'A')
|
||||||
|
valid = true
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
out = append(out, r)
|
||||||
|
lastDash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastDash && len(out) > 0 {
|
||||||
|
out = append(out, '-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(out) > 0 && out[len(out)-1] == '-' {
|
||||||
|
out = out[:len(out)-1]
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return DefaultWorkspaceName
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceClusterBinding struct {
|
||||||
|
ID string
|
||||||
|
WorkspaceID string
|
||||||
|
ClusterID string
|
||||||
|
Namespace string
|
||||||
|
ServiceAccount string
|
||||||
|
QuotaCPU string
|
||||||
|
QuotaMemory string
|
||||||
|
QuotaGPU string
|
||||||
|
QuotaGPUMem string
|
||||||
|
Status string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
ID string
|
||||||
|
WorkspaceID string
|
||||||
|
UserID string
|
||||||
|
Action string
|
||||||
|
ResourceType string
|
||||||
|
ResourceID string
|
||||||
|
ResourceName string
|
||||||
|
Details map[string]interface{}
|
||||||
|
IPAddress string
|
||||||
|
UserAgent string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@ -9,20 +9,19 @@ import (
|
|||||||
type ClusterRepository interface {
|
type ClusterRepository interface {
|
||||||
// Create 创建集群
|
// Create 创建集群
|
||||||
Create(ctx context.Context, cluster *entity.Cluster) error
|
Create(ctx context.Context, cluster *entity.Cluster) error
|
||||||
|
|
||||||
// GetByID 根据 ID 获取集群
|
// GetByID 根据 ID 获取集群
|
||||||
GetByID(ctx context.Context, id string) (*entity.Cluster, error)
|
GetByID(ctx context.Context, id string) (*entity.Cluster, error)
|
||||||
|
|
||||||
// GetByName 根据名称获取集群
|
// GetByName 根据名称获取集群
|
||||||
GetByName(ctx context.Context, name string) (*entity.Cluster, error)
|
GetByName(ctx context.Context, name string) (*entity.Cluster, error)
|
||||||
|
|
||||||
// Update 更新集群
|
// Update 更新集群
|
||||||
Update(ctx context.Context, cluster *entity.Cluster) error
|
Update(ctx context.Context, cluster *entity.Cluster) error
|
||||||
|
|
||||||
// Delete 删除集群
|
// Delete 删除集群
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
// List 列出所有集群
|
// List 列出所有集群
|
||||||
List(ctx context.Context) ([]*entity.Cluster, error)
|
List(ctx context.Context) ([]*entity.Cluster, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,26 +9,28 @@ import (
|
|||||||
type HelmClient interface {
|
type HelmClient interface {
|
||||||
// Install 安装 Helm Chart
|
// Install 安装 Helm Chart
|
||||||
Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||||
|
|
||||||
// Upgrade 升级 Helm Release
|
// Upgrade 升级 Helm Release
|
||||||
Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||||
|
|
||||||
// Uninstall 卸载 Helm Release
|
// Uninstall 卸载 Helm Release
|
||||||
Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error
|
Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error
|
||||||
|
|
||||||
// Rollback 回滚 Helm Release
|
// Rollback 回滚 Helm Release
|
||||||
Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error
|
Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error
|
||||||
|
|
||||||
// GetStatus 获取 Release 状态
|
// GetStatus 获取 Release 状态
|
||||||
GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error)
|
GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error)
|
||||||
|
|
||||||
// GetHistory 获取 Release 历史
|
// GetHistory 获取 Release 历史
|
||||||
GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error)
|
GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error)
|
||||||
|
|
||||||
// List 列出集群中的所有 Releases
|
// List 列出集群中的所有 Releases
|
||||||
List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error)
|
List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error)
|
||||||
|
|
||||||
// GetValues 获取 Release 的 values
|
// GetValues 获取 Release 的 values
|
||||||
GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error)
|
GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error)
|
||||||
}
|
|
||||||
|
|
||||||
|
// GetChartDefaultValues 从 chart 包中读取默认 values
|
||||||
|
GetChartDefaultValues(chartPath string) (map[string]interface{}, error)
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstanceDiagnosticsClient interface {
|
||||||
|
GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodLogStreamer streams pod log lines over channels. The caller reads from the
|
||||||
|
// lines channel until it is closed; errors are sent to the errs channel.
|
||||||
|
type PodLogStreamer interface {
|
||||||
|
StreamPodLogs(ctx context.Context, cluster *entity.Cluster, namespace, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error)
|
||||||
|
}
|
||||||
@ -9,23 +9,22 @@ import (
|
|||||||
type InstanceRepository interface {
|
type InstanceRepository interface {
|
||||||
// Create 创建实例
|
// Create 创建实例
|
||||||
Create(ctx context.Context, instance *entity.Instance) error
|
Create(ctx context.Context, instance *entity.Instance) error
|
||||||
|
|
||||||
// GetByID 根据 ID 获取实例
|
// GetByID 根据 ID 获取实例
|
||||||
GetByID(ctx context.Context, id string) (*entity.Instance, error)
|
GetByID(ctx context.Context, id string) (*entity.Instance, error)
|
||||||
|
|
||||||
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
||||||
GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error)
|
GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error)
|
||||||
|
|
||||||
// Update 更新实例
|
// Update 更新实例
|
||||||
Update(ctx context.Context, instance *entity.Instance) error
|
Update(ctx context.Context, instance *entity.Instance) error
|
||||||
|
|
||||||
// Delete 删除实例
|
// Delete 删除实例
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
// ListByCluster 列出指定集群的所有实例
|
// ListByCluster 列出指定集群的所有实例
|
||||||
ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error)
|
ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error)
|
||||||
|
|
||||||
// List 列出所有实例
|
// List 列出所有实例
|
||||||
List(ctx context.Context) ([]*entity.Instance, error)
|
List(ctx context.Context) ([]*entity.Instance, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,7 @@ import (
|
|||||||
type MetricsClient interface {
|
type MetricsClient interface {
|
||||||
// GetClusterMetrics 获取集群的监控指标
|
// GetClusterMetrics 获取集群的监控指标
|
||||||
GetClusterMetrics(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error)
|
GetClusterMetrics(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error)
|
||||||
|
|
||||||
// GetNodeMetrics 获取集群的节点指标
|
// GetNodeMetrics 获取集群的节点指标
|
||||||
GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error)
|
GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,26 +7,29 @@ import (
|
|||||||
|
|
||||||
// OCIClient OCI Registry 客户端接口(Output Port)
|
// OCIClient OCI Registry 客户端接口(Output Port)
|
||||||
type OCIClient interface {
|
type OCIClient interface {
|
||||||
// ListRepositories 列出 Registry 中的所有 repositories
|
// ListRepositories 列出 Registry 中的 repositories.
|
||||||
ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error)
|
// artifactType 支持 "chart" 和 "all",默认由调用方决定。
|
||||||
|
ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error)
|
||||||
|
|
||||||
// ListArtifacts 列出指定 repository 的所有 artifacts
|
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||||
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||||
ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error)
|
ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error)
|
||||||
|
|
||||||
// GetArtifact 获取指定 artifact 的详细信息
|
// GetArtifact 获取指定 artifact 的详细信息
|
||||||
GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error)
|
GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error)
|
||||||
|
|
||||||
// GetValuesSchema 获取 Helm Chart 的 values schema
|
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||||
GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
|
GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
|
||||||
|
|
||||||
|
// GetValuesYAML 获取 Helm Chart 原始 values.yaml
|
||||||
|
GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
|
||||||
|
|
||||||
// PullArtifact 下载 artifact 到本地
|
// PullArtifact 下载 artifact 到本地
|
||||||
PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error
|
PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error
|
||||||
|
|
||||||
// PushArtifact 推送 artifact 到 Registry
|
// PushArtifact 推送 artifact 到 Registry
|
||||||
PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error
|
PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error
|
||||||
|
|
||||||
// CheckHealth 检查 Registry 健康状态
|
// CheckHealth 检查 Registry 健康状态
|
||||||
CheckHealth(ctx context.Context, registry *entity.Registry) error
|
CheckHealth(ctx context.Context, registry *entity.Registry) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,20 +9,19 @@ import (
|
|||||||
type RegistryRepository interface {
|
type RegistryRepository interface {
|
||||||
// Create 创建 Registry
|
// Create 创建 Registry
|
||||||
Create(ctx context.Context, registry *entity.Registry) error
|
Create(ctx context.Context, registry *entity.Registry) error
|
||||||
|
|
||||||
// GetByID 根据 ID 获取 Registry
|
// GetByID 根据 ID 获取 Registry
|
||||||
GetByID(ctx context.Context, id string) (*entity.Registry, error)
|
GetByID(ctx context.Context, id string) (*entity.Registry, error)
|
||||||
|
|
||||||
// GetByName 根据名称获取 Registry
|
// GetByName 根据名称获取 Registry
|
||||||
GetByName(ctx context.Context, name string) (*entity.Registry, error)
|
GetByName(ctx context.Context, name string) (*entity.Registry, error)
|
||||||
|
|
||||||
// Update 更新 Registry
|
// Update 更新 Registry
|
||||||
Update(ctx context.Context, registry *entity.Registry) error
|
Update(ctx context.Context, registry *entity.Registry) error
|
||||||
|
|
||||||
// Delete 删除 Registry
|
// Delete 删除 Registry
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
// List 列出所有 Registries
|
// List 列出所有 Registries
|
||||||
List(ctx context.Context) ([]*entity.Registry, error)
|
List(ctx context.Context) ([]*entity.Registry, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
backend/internal/domain/repository/tenant_kube_client.go
Normal file
15
backend/internal/domain/repository/tenant_kube_client.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TenantKubeClient provisions namespace-scoped Kubernetes access for tenants.
|
||||||
|
type TenantKubeClient interface {
|
||||||
|
EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error
|
||||||
|
IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error)
|
||||||
|
SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error
|
||||||
|
}
|
||||||
@ -9,20 +9,19 @@ import (
|
|||||||
type UserRepository interface {
|
type UserRepository interface {
|
||||||
// Create 创建用户
|
// Create 创建用户
|
||||||
Create(ctx context.Context, user *entity.User) error
|
Create(ctx context.Context, user *entity.User) error
|
||||||
|
|
||||||
// GetByID 根据 ID 获取用户
|
// GetByID 根据 ID 获取用户
|
||||||
GetByID(ctx context.Context, id string) (*entity.User, error)
|
GetByID(ctx context.Context, id string) (*entity.User, error)
|
||||||
|
|
||||||
// GetByUsername 根据用户名获取用户
|
// GetByUsername 根据用户名获取用户
|
||||||
GetByUsername(ctx context.Context, username string) (*entity.User, error)
|
GetByUsername(ctx context.Context, username string) (*entity.User, error)
|
||||||
|
|
||||||
// Update 更新用户
|
// Update 更新用户
|
||||||
Update(ctx context.Context, user *entity.User) error
|
Update(ctx context.Context, user *entity.User) error
|
||||||
|
|
||||||
// Delete 删除用户
|
// Delete 删除用户
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
// List 列出所有用户
|
// List 列出所有用户
|
||||||
List(ctx context.Context) ([]*entity.User, error)
|
List(ctx context.Context) ([]*entity.User, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
backend/internal/domain/repository/workspace_repository.go
Normal file
26
backend/internal/domain/repository/workspace_repository.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceRepository interface {
|
||||||
|
Create(ctx context.Context, workspace *entity.Workspace) error
|
||||||
|
GetByID(ctx context.Context, id string) (*entity.Workspace, error)
|
||||||
|
GetByName(ctx context.Context, name string) (*entity.Workspace, error)
|
||||||
|
Update(ctx context.Context, workspace *entity.Workspace) error
|
||||||
|
List(ctx context.Context) ([]*entity.Workspace, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkspaceClusterBindingRepository interface {
|
||||||
|
Upsert(ctx context.Context, binding *entity.WorkspaceClusterBinding) error
|
||||||
|
Get(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error)
|
||||||
|
Delete(ctx context.Context, workspaceID, clusterID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogRepository interface {
|
||||||
|
Create(ctx context.Context, log *entity.AuditLog) error
|
||||||
|
ListByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error)
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ArtifactService Artifact 浏览领域服务
|
// ArtifactService Artifact 浏览领域服务
|
||||||
@ -25,22 +26,22 @@ func NewArtifactService(
|
|||||||
|
|
||||||
// GetRegistry 获取 Registry 信息
|
// GetRegistry 获取 Registry 信息
|
||||||
func (s *ArtifactService) GetRegistry(ctx context.Context, registryID string) (*entity.Registry, error) {
|
func (s *ArtifactService) GetRegistry(ctx context.Context, registryID string) (*entity.Registry, error) {
|
||||||
return s.registryRepo.GetByID(ctx, registryID)
|
return s.visibleRegistry(ctx, registryID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRepositories 列出 Registry 中的所有 repositories
|
// ListRepositories 列出 Registry 中的 repositories
|
||||||
func (s *ArtifactService) ListRepositories(ctx context.Context, registryID string) ([]string, error) {
|
func (s *ArtifactService) ListRepositories(ctx context.Context, registryID, artifactType string) ([]string, error) {
|
||||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
registry, err := s.visibleRegistry(ctx, registryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, entity.ErrRegistryNotFound
|
return nil, entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.ociClient.ListRepositories(ctx, registry)
|
return s.ociClient.ListRepositories(ctx, registry, artifactType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListArtifacts 列出 repository 中的所有 artifacts
|
// ListArtifacts 列出 repository 中的所有 artifacts
|
||||||
func (s *ArtifactService) ListArtifacts(ctx context.Context, registryID, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
func (s *ArtifactService) ListArtifacts(ctx context.Context, registryID, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
registry, err := s.visibleRegistry(ctx, registryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, entity.ErrRegistryNotFound
|
return nil, entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
@ -50,7 +51,7 @@ func (s *ArtifactService) ListArtifacts(ctx context.Context, registryID, reposit
|
|||||||
|
|
||||||
// GetArtifact 获取 artifact 详情
|
// GetArtifact 获取 artifact 详情
|
||||||
func (s *ArtifactService) GetArtifact(ctx context.Context, registryID, repository, reference string) (*entity.Artifact, error) {
|
func (s *ArtifactService) GetArtifact(ctx context.Context, registryID, repository, reference string) (*entity.Artifact, error) {
|
||||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
registry, err := s.visibleRegistry(ctx, registryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, entity.ErrRegistryNotFound
|
return nil, entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
@ -60,7 +61,7 @@ func (s *ArtifactService) GetArtifact(ctx context.Context, registryID, repositor
|
|||||||
|
|
||||||
// GetValuesSchema 获取 Helm Chart 的 values schema
|
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||||
func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repository, reference string) (string, error) {
|
func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repository, reference string) (string, error) {
|
||||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
registry, err := s.visibleRegistry(ctx, registryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", entity.ErrRegistryNotFound
|
return "", entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
@ -68,9 +69,19 @@ func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repos
|
|||||||
return s.ociClient.GetValuesSchema(ctx, registry, repository, reference)
|
return s.ociClient.GetValuesSchema(ctx, registry, repository, reference)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetValuesYAML 获取 Helm Chart 的原始 values.yaml
|
||||||
|
func (s *ArtifactService) GetValuesYAML(ctx context.Context, registryID, repository, reference string) (string, error) {
|
||||||
|
registry, err := s.visibleRegistry(ctx, registryID)
|
||||||
|
if err != nil {
|
||||||
|
return "", entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.ociClient.GetValuesYAML(ctx, registry, repository, reference)
|
||||||
|
}
|
||||||
|
|
||||||
// PullArtifact 下载 artifact
|
// PullArtifact 下载 artifact
|
||||||
func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error {
|
func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error {
|
||||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
registry, err := s.visibleRegistry(ctx, registryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
@ -78,3 +89,17 @@ func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, reposito
|
|||||||
return s.ociClient.PullArtifact(ctx, registry, repository, reference, destPath)
|
return s.ociClient.PullArtifact(ctx, registry, repository, reference, destPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ArtifactService) visibleRegistry(ctx context.Context, registryID string) (*entity.Registry, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
if !authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
return registry, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,22 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
|
jwtpkg "github.com/ocdp/cluster-service/internal/pkg/jwt"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthService 认证领域服务
|
// AuthService 认证领域服务
|
||||||
type AuthService struct {
|
type AuthService struct {
|
||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
|
workspaceRepo repository.WorkspaceRepository
|
||||||
passwordHasher PasswordHasher
|
passwordHasher PasswordHasher
|
||||||
tokenGenerator TokenGenerator
|
tokenGenerator TokenGenerator
|
||||||
}
|
}
|
||||||
@ -22,27 +30,48 @@ type PasswordHasher interface {
|
|||||||
|
|
||||||
// TokenGenerator Token 生成器接口
|
// TokenGenerator Token 生成器接口
|
||||||
type TokenGenerator interface {
|
type TokenGenerator interface {
|
||||||
Generate(userID, username string) (accessToken, refreshToken string, err error)
|
Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error)
|
||||||
Verify(token string) (userID, username string, err error)
|
Verify(token string) (userID, username string, err error)
|
||||||
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
|
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
|
||||||
|
VerifyAccess(token string) (*jwtpkg.Claims, error)
|
||||||
|
VerifyRefresh(token string) (*jwtpkg.Claims, error)
|
||||||
Refresh(refreshToken string) (newAccessToken string, err error)
|
Refresh(refreshToken string) (newAccessToken string, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthService 创建认证服务
|
// NewAuthService 创建认证服务
|
||||||
func NewAuthService(
|
func NewAuthService(
|
||||||
userRepo repository.UserRepository,
|
userRepo repository.UserRepository,
|
||||||
|
workspaceRepo repository.WorkspaceRepository,
|
||||||
passwordHasher PasswordHasher,
|
passwordHasher PasswordHasher,
|
||||||
tokenGenerator TokenGenerator,
|
tokenGenerator TokenGenerator,
|
||||||
) *AuthService {
|
) *AuthService {
|
||||||
return &AuthService{
|
return &AuthService{
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
|
workspaceRepo: workspaceRepo,
|
||||||
passwordHasher: passwordHasher,
|
passwordHasher: passwordHasher,
|
||||||
tokenGenerator: tokenGenerator,
|
tokenGenerator: tokenGenerator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register 注册新用户(仅需用户名和密码,邮箱将自动补全)
|
// Register 注册新用户。业务入口只允许 admin 调用;初始 admin 由 bootstrap seeder 创建。
|
||||||
func (s *AuthService) Register(ctx context.Context, username, password string) (*entity.User, error) {
|
type UserWorkspaceOptions struct {
|
||||||
|
Namespace string
|
||||||
|
DefaultClusterID string
|
||||||
|
QuotaCPU string
|
||||||
|
QuotaMemory string
|
||||||
|
QuotaGPU string
|
||||||
|
QuotaGPUMem string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) Register(ctx context.Context, username, password, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
// 检查用户是否已存在
|
// 检查用户是否已存在
|
||||||
existingUser, _ := s.userRepo.GetByUsername(ctx, username)
|
existingUser, _ := s.userRepo.GetByUsername(ctx, username)
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
@ -54,6 +83,10 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// 默认生成占位邮箱,避免数据库约束失败
|
// 默认生成占位邮箱,避免数据库约束失败
|
||||||
email := username + "@local.ocdp"
|
email := username + "@local.ocdp"
|
||||||
@ -61,6 +94,27 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
|||||||
// 创建用户
|
// 创建用户
|
||||||
user := entity.NewUser(username, passwordHash, email)
|
user := entity.NewUser(username, passwordHash, email)
|
||||||
user.ID = uuid.New().String()
|
user.ID = uuid.New().String()
|
||||||
|
user.Role = normalizeUserRole(role)
|
||||||
|
user.WorkspaceID = workspaceID
|
||||||
|
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||||
|
workspace, err := s.createUserWorkspace(ctx, username, principal.UserID, normalizedOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user.WorkspaceID = workspace.ID
|
||||||
|
}
|
||||||
|
if user.WorkspaceID == "" {
|
||||||
|
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||||
|
}
|
||||||
|
if user.Role == authz.RoleAdmin {
|
||||||
|
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||||
|
}
|
||||||
|
if isActive != nil {
|
||||||
|
user.IsActive = *isActive
|
||||||
|
}
|
||||||
|
if mustChangePassword != nil {
|
||||||
|
user.MustChangePassword = *mustChangePassword
|
||||||
|
}
|
||||||
|
|
||||||
if err := user.Validate(); err != nil {
|
if err := user.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -73,31 +127,241 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 用户登录
|
func (s *AuthService) createUserWorkspace(ctx context.Context, username, createdBy string, opts UserWorkspaceOptions) (*entity.Workspace, error) {
|
||||||
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, err error) {
|
if s.workspaceRepo == nil {
|
||||||
// 查找用户
|
return nil, entity.ErrWorkspaceNotFound
|
||||||
user, err := s.userRepo.GetByUsername(ctx, username)
|
}
|
||||||
|
name := strings.TrimPrefix(entity.NamespaceForUser(username), "ocdp-u-")
|
||||||
|
workspace := entity.NewWorkspace(name, createdBy)
|
||||||
|
workspace.ID = uuid.New().String()
|
||||||
|
workspace.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||||
|
namespace := strings.TrimSpace(opts.Namespace)
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = entity.NamespaceForUser(username)
|
||||||
|
}
|
||||||
|
if namespace != "" {
|
||||||
|
if len(validation.IsDNS1123Label(namespace)) > 0 {
|
||||||
|
return nil, entity.ErrInvalidNamespace
|
||||||
|
}
|
||||||
|
workspace.K8sNamespace = namespace
|
||||||
|
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||||
|
}
|
||||||
|
workspace.QuotaCPU = strings.TrimSpace(opts.QuotaCPU)
|
||||||
|
workspace.QuotaMemory = strings.TrimSpace(opts.QuotaMemory)
|
||||||
|
workspace.QuotaGPU = strings.TrimSpace(opts.QuotaGPU)
|
||||||
|
workspace.QuotaGPUMem = strings.TrimSpace(opts.QuotaGPUMem)
|
||||||
|
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return workspace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeQuotaOptions(opts UserWorkspaceOptions) (UserWorkspaceOptions, error) {
|
||||||
|
opts.Namespace = strings.TrimSpace(opts.Namespace)
|
||||||
|
opts.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||||
|
opts.QuotaCPU = normalizeStandardQuotaQuantity(opts.QuotaCPU)
|
||||||
|
opts.QuotaMemory = normalizeStandardQuotaQuantity(opts.QuotaMemory)
|
||||||
|
opts.QuotaGPU = normalizeStandardQuotaQuantity(opts.QuotaGPU)
|
||||||
|
gpuMem, err := normalizeGPUMemoryQuota(opts.QuotaGPUMem)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", entity.ErrUserNotFound
|
return opts, err
|
||||||
|
}
|
||||||
|
opts.QuotaGPUMem = gpuMem
|
||||||
|
for _, value := range []string{opts.QuotaCPU, opts.QuotaMemory, opts.QuotaGPU} {
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := resource.ParseQuantity(value); err != nil {
|
||||||
|
return opts, entity.ErrInvalidTenantResourceQuota
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if opts.Namespace != "" && len(validation.IsDNS1123Label(opts.Namespace)) > 0 {
|
||||||
|
return opts, entity.ErrInvalidNamespace
|
||||||
|
}
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ListUsers(ctx context.Context) ([]*entity.User, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
return s.userRepo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) UpdateUser(ctx context.Context, userID, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
user, err := s.userRepo.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if role != "" {
|
||||||
|
user.Role = normalizeUserRole(role)
|
||||||
|
}
|
||||||
|
if workspaceID != "" {
|
||||||
|
user.WorkspaceID = workspaceID
|
||||||
|
}
|
||||||
|
if user.Role == authz.RoleAdmin {
|
||||||
|
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||||
|
}
|
||||||
|
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||||
|
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
workspace, err := s.createUserWorkspace(ctx, user.Username, principal.UserID, normalizedOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user.WorkspaceID = workspace.ID
|
||||||
|
}
|
||||||
|
if isActive != nil {
|
||||||
|
if user.ID == principal.UserID && !*isActive {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
user.IsActive = *isActive
|
||||||
|
}
|
||||||
|
if mustChangePassword != nil {
|
||||||
|
user.MustChangePassword = *mustChangePassword
|
||||||
|
}
|
||||||
|
if user.Role != authz.RoleAdmin && hasWorkspaceUpdates(opts) {
|
||||||
|
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applyWorkspaceOptions(workspace, normalizedOpts)
|
||||||
|
if err := s.workspaceRepo.Update(ctx, workspace); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.RevokedAfter = time.Now()
|
||||||
|
user.UpdatedAt = time.Now()
|
||||||
|
if err := user.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasWorkspaceUpdates(opts UserWorkspaceOptions) bool {
|
||||||
|
return strings.TrimSpace(opts.Namespace) != "" ||
|
||||||
|
strings.TrimSpace(opts.DefaultClusterID) != "" ||
|
||||||
|
strings.TrimSpace(opts.QuotaCPU) != "" ||
|
||||||
|
strings.TrimSpace(opts.QuotaMemory) != "" ||
|
||||||
|
strings.TrimSpace(opts.QuotaGPU) != "" ||
|
||||||
|
strings.TrimSpace(opts.QuotaGPUMem) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyWorkspaceOptions(workspace *entity.Workspace, opts UserWorkspaceOptions) {
|
||||||
|
if namespace := strings.TrimSpace(opts.Namespace); namespace != "" {
|
||||||
|
workspace.K8sNamespace = namespace
|
||||||
|
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(opts.DefaultClusterID); value != "" {
|
||||||
|
workspace.DefaultClusterID = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(opts.QuotaCPU); value != "" {
|
||||||
|
workspace.QuotaCPU = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(opts.QuotaMemory); value != "" {
|
||||||
|
workspace.QuotaMemory = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(opts.QuotaGPU); value != "" {
|
||||||
|
workspace.QuotaGPU = value
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(opts.QuotaGPUMem); value != "" {
|
||||||
|
workspace.QuotaGPUMem = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) DeleteUser(ctx context.Context, userID string) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
if userID == principal.UserID {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
return s.userRepo.Delete(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeUserRole(role string) string {
|
||||||
|
if role == authz.RoleAdmin {
|
||||||
|
return authz.RoleAdmin
|
||||||
|
}
|
||||||
|
return authz.RoleUser
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 用户登录
|
||||||
|
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, user *entity.User, err error) {
|
||||||
|
// 查找用户
|
||||||
|
user, err = s.userRepo.GetByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if !user.IsActive {
|
||||||
|
return "", "", nil, entity.ErrUserInactive
|
||||||
|
}
|
||||||
|
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证密码
|
// 验证密码
|
||||||
if err := s.passwordHasher.Verify(password, user.PasswordHash); err != nil {
|
if err := s.passwordHasher.Verify(password, user.PasswordHash); err != nil {
|
||||||
return "", "", entity.ErrInvalidPassword
|
return "", "", nil, entity.ErrInvalidPassword
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 Token
|
// 生成 Token
|
||||||
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username)
|
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return accessToken, refreshToken, nil
|
return accessToken, refreshToken, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新 Token
|
// RefreshToken 刷新 Token
|
||||||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
|
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, *entity.User, error) {
|
||||||
return s.tokenGenerator.Refresh(refreshToken)
|
claims, err := s.tokenGenerator.VerifyRefresh(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if !user.IsActive {
|
||||||
|
return "", nil, entity.ErrUserInactive
|
||||||
|
}
|
||||||
|
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||||||
|
return "", nil, entity.ErrTokenRevoked
|
||||||
|
}
|
||||||
|
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
accessToken, _, err := s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return accessToken, user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByID 根据 ID 获取用户
|
// GetUserByID 根据 ID 获取用户
|
||||||
@ -106,25 +370,84 @@ func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VerifyAccessToken 验证 Access Token(包括 revoked_after 检查)
|
// VerifyAccessToken 验证 Access Token(包括 revoked_after 检查)
|
||||||
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) {
|
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (*authz.Principal, error) {
|
||||||
// 1. JWT 自验证
|
// 1. JWT 自验证
|
||||||
userID, username, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
|
claims, err := s.tokenGenerator.VerifyAccess(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 检查用户级别的撤销时间
|
// 2. 检查用户级别的撤销时间
|
||||||
user, err := s.userRepo.GetByID(ctx, userID)
|
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", entity.ErrUserNotFound
|
return nil, entity.ErrUserNotFound
|
||||||
|
}
|
||||||
|
if !user.IsActive {
|
||||||
|
return nil, entity.ErrUserInactive
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 如果 Token 签发时间早于 revoked_after,则失效
|
// 3. 如果 Token 签发时间早于 revoked_after,则失效
|
||||||
if issuedAt < user.RevokedAfter.Unix() {
|
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||||||
return "", "", entity.ErrTokenRevoked
|
return nil, entity.ErrTokenRevoked
|
||||||
|
}
|
||||||
|
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
workspaceName := ""
|
||||||
|
namespace := ""
|
||||||
|
defaultClusterID := ""
|
||||||
|
quotaCPU := ""
|
||||||
|
quotaMemory := ""
|
||||||
|
quotaGPU := ""
|
||||||
|
quotaGPUMem := ""
|
||||||
|
if s.workspaceRepo != nil && user.WorkspaceID != "" {
|
||||||
|
if workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID); err == nil && workspace != nil {
|
||||||
|
workspaceName = workspace.Name
|
||||||
|
namespace = workspace.K8sNamespace
|
||||||
|
defaultClusterID = workspace.DefaultClusterID
|
||||||
|
quotaCPU = workspace.QuotaCPU
|
||||||
|
quotaMemory = workspace.QuotaMemory
|
||||||
|
quotaGPU = workspace.QuotaGPU
|
||||||
|
quotaGPUMem = workspace.QuotaGPUMem
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return userID, username, nil
|
return &authz.Principal{
|
||||||
|
UserID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Role: user.Role,
|
||||||
|
WorkspaceID: user.WorkspaceID,
|
||||||
|
WorkspaceName: workspaceName,
|
||||||
|
Namespace: namespace,
|
||||||
|
DefaultClusterID: defaultClusterID,
|
||||||
|
QuotaCPU: quotaCPU,
|
||||||
|
QuotaMemory: quotaMemory,
|
||||||
|
QuotaGPU: quotaGPU,
|
||||||
|
QuotaGPUMem: quotaGPUMem,
|
||||||
|
Permissions: authz.PermissionsForRole(user.Role),
|
||||||
|
PermissionVersion: 1,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) GetWorkspaceByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||||||
|
if s.workspaceRepo == nil || id == "" {
|
||||||
|
return nil, entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
return s.workspaceRepo.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AuthService) ensureWorkspaceActive(ctx context.Context, user *entity.User) error {
|
||||||
|
if user.Role == authz.RoleAdmin || user.WorkspaceID == "" || s.workspaceRepo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrWorkspaceNotFound
|
||||||
|
}
|
||||||
|
if workspace.Status == entity.WorkspaceSuspended {
|
||||||
|
return entity.ErrWorkspaceSuspended
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChangePassword 修改密码(会触发全局登出)
|
// ChangePassword 修改密码(会触发全局登出)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClusterService 集群管理领域服务
|
// ClusterService 集群管理领域服务
|
||||||
@ -21,8 +22,21 @@ func NewClusterService(clusterRepo repository.ClusterRepository) *ClusterService
|
|||||||
|
|
||||||
// CreateCluster 创建新集群
|
// CreateCluster 创建新集群
|
||||||
func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 生成 ID
|
// 生成 ID
|
||||||
cluster.ID = uuid.New().String()
|
cluster.ID = uuid.New().String()
|
||||||
|
cluster.OwnerID = principal.UserID
|
||||||
|
cluster.WorkspaceID = principal.WorkspaceID
|
||||||
|
if principal.IsAdmin() && cluster.WorkspaceID == "" {
|
||||||
|
cluster.WorkspaceID = entity.DefaultWorkspaceID
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() && cluster.Visibility == authz.VisibilityGlobalShared {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
cluster.Visibility = authz.NormalizeVisibility(principal.Role, cluster.Visibility)
|
||||||
|
|
||||||
// 验证
|
// 验证
|
||||||
if err := cluster.Validate(); err != nil {
|
if err := cluster.Validate(); err != nil {
|
||||||
@ -30,9 +44,11 @@ func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Clus
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已存在
|
// 检查是否已存在
|
||||||
existingCluster, _ := s.clusterRepo.GetByName(ctx, cluster.Name)
|
clusters, _ := s.clusterRepo.List(ctx)
|
||||||
if existingCluster != nil {
|
for _, existingCluster := range clusters {
|
||||||
return entity.ErrClusterExists
|
if existingCluster.Name == cluster.Name && existingCluster.WorkspaceID == cluster.WorkspaceID && existingCluster.OwnerID == cluster.OwnerID {
|
||||||
|
return entity.ErrClusterExists
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.clusterRepo.Create(ctx, cluster)
|
return s.clusterRepo.Create(ctx, cluster)
|
||||||
@ -40,16 +56,41 @@ func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Clus
|
|||||||
|
|
||||||
// GetCluster 获取集群
|
// GetCluster 获取集群
|
||||||
func (s *ClusterService) GetCluster(ctx context.Context, id string) (*entity.Cluster, error) {
|
func (s *ClusterService) GetCluster(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||||
return s.clusterRepo.GetByID(ctx, id)
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
return cluster, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCluster 更新集群
|
// UpdateCluster 更新集群
|
||||||
func (s *ClusterService) UpdateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
func (s *ClusterService) UpdateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查是否存在
|
// 检查是否存在
|
||||||
_, err := s.clusterRepo.GetByID(ctx, cluster.ID)
|
existing, err := s.clusterRepo.GetByID(ctx, cluster.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrClusterNotFound
|
return entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
if !authz.CanWriteResource(principal, existing.WorkspaceID, existing.OwnerID, existing.Visibility) {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
cluster.WorkspaceID = existing.WorkspaceID
|
||||||
|
cluster.OwnerID = existing.OwnerID
|
||||||
|
if principal.IsAdmin() {
|
||||||
|
cluster.Visibility = authz.NormalizeVisibility(principal.Role, cluster.Visibility)
|
||||||
|
} else {
|
||||||
|
cluster.Visibility = existing.Visibility
|
||||||
|
}
|
||||||
|
|
||||||
// 验证
|
// 验证
|
||||||
if err := cluster.Validate(); err != nil {
|
if err := cluster.Validate(); err != nil {
|
||||||
@ -61,17 +102,37 @@ func (s *ClusterService) UpdateCluster(ctx context.Context, cluster *entity.Clus
|
|||||||
|
|
||||||
// DeleteCluster 删除集群
|
// DeleteCluster 删除集群
|
||||||
func (s *ClusterService) DeleteCluster(ctx context.Context, id string) error {
|
func (s *ClusterService) DeleteCluster(ctx context.Context, id string) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查是否存在
|
// 检查是否存在
|
||||||
_, err := s.clusterRepo.GetByID(ctx, id)
|
cluster, err := s.clusterRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrClusterNotFound
|
return entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
if !authz.CanWriteResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
return s.clusterRepo.Delete(ctx, id)
|
return s.clusterRepo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListClusters 列出所有集群
|
// ListClusters 列出所有集群
|
||||||
func (s *ClusterService) ListClusters(ctx context.Context) ([]*entity.Cluster, error) {
|
func (s *ClusterService) ListClusters(ctx context.Context) ([]*entity.Cluster, error) {
|
||||||
return s.clusterRepo.List(ctx)
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
clusters, err := s.clusterRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
visible := make([]*entity.Cluster, 0, len(clusters))
|
||||||
|
for _, cluster := range clusters {
|
||||||
|
if authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
visible = append(visible, cluster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,18 +9,33 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ScaleClient defines the interface for K8s-native workload scaling
|
||||||
|
type ScaleClient interface {
|
||||||
|
GetDeploymentReplicas(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string) (int32, error)
|
||||||
|
ScaleDeployment(ctx context.Context, cluster *entity.Cluster, namespace, releaseName string, replicas int32) error
|
||||||
|
}
|
||||||
|
|
||||||
// InstanceService Helm 实例管理领域服务
|
// InstanceService Helm 实例管理领域服务
|
||||||
type InstanceService struct {
|
type InstanceService struct {
|
||||||
instanceRepo repository.InstanceRepository
|
instanceRepo repository.InstanceRepository
|
||||||
clusterRepo repository.ClusterRepository
|
clusterRepo repository.ClusterRepository
|
||||||
registryRepo repository.RegistryRepository
|
registryRepo repository.RegistryRepository
|
||||||
helmClient repository.HelmClient
|
bindingRepo repository.WorkspaceClusterBindingRepository
|
||||||
ociClient repository.OCIClient
|
helmClient repository.HelmClient
|
||||||
entryClient repository.InstanceEntryClient
|
ociClient repository.OCIClient
|
||||||
|
entryClient repository.InstanceEntryClient
|
||||||
|
diagClient repository.InstanceDiagnosticsClient
|
||||||
|
workspaceRepo repository.WorkspaceRepository
|
||||||
|
tenantClient repository.TenantKubeClient
|
||||||
|
scaleClient ScaleClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInstanceService 创建实例服务
|
// NewInstanceService 创建实例服务
|
||||||
@ -31,17 +46,36 @@ func NewInstanceService(
|
|||||||
helmClient repository.HelmClient,
|
helmClient repository.HelmClient,
|
||||||
ociClient repository.OCIClient,
|
ociClient repository.OCIClient,
|
||||||
entryClient repository.InstanceEntryClient,
|
entryClient repository.InstanceEntryClient,
|
||||||
|
bindingRepo ...repository.WorkspaceClusterBindingRepository,
|
||||||
) *InstanceService {
|
) *InstanceService {
|
||||||
|
var workspaceBindingRepo repository.WorkspaceClusterBindingRepository
|
||||||
|
if len(bindingRepo) > 0 {
|
||||||
|
workspaceBindingRepo = bindingRepo[0]
|
||||||
|
}
|
||||||
return &InstanceService{
|
return &InstanceService{
|
||||||
instanceRepo: instanceRepo,
|
instanceRepo: instanceRepo,
|
||||||
clusterRepo: clusterRepo,
|
clusterRepo: clusterRepo,
|
||||||
registryRepo: registryRepo,
|
registryRepo: registryRepo,
|
||||||
|
bindingRepo: workspaceBindingRepo,
|
||||||
helmClient: helmClient,
|
helmClient: helmClient,
|
||||||
ociClient: ociClient,
|
ociClient: ociClient,
|
||||||
entryClient: entryClient,
|
entryClient: entryClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) SetDiagnosticsClient(client repository.InstanceDiagnosticsClient) {
|
||||||
|
s.diagClient = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) SetScaleClient(client ScaleClient) {
|
||||||
|
s.scaleClient = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) SetTenantProvisioning(workspaceRepo repository.WorkspaceRepository, tenantClient repository.TenantKubeClient) {
|
||||||
|
s.workspaceRepo = workspaceRepo
|
||||||
|
s.tenantClient = tenantClient
|
||||||
|
}
|
||||||
|
|
||||||
const chartCacheDir = "/tmp/charts"
|
const chartCacheDir = "/tmp/charts"
|
||||||
|
|
||||||
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
|
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
|
||||||
@ -62,8 +96,14 @@ func (s *InstanceService) downloadChart(ctx context.Context, registry *entity.Re
|
|||||||
|
|
||||||
// CreateInstance 创建(安装)新实例
|
// CreateInstance 创建(安装)新实例
|
||||||
func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.Instance) error {
|
func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 生成 ID
|
// 生成 ID
|
||||||
instance.ID = uuid.New().String()
|
instance.ID = uuid.New().String()
|
||||||
|
instance.WorkspaceID = principal.WorkspaceID
|
||||||
|
instance.OwnerID = principal.UserID
|
||||||
|
|
||||||
// 验证
|
// 验证
|
||||||
if err := instance.Validate(); err != nil {
|
if err := instance.Validate(); err != nil {
|
||||||
@ -75,12 +115,25 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrClusterNotFound
|
return entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
// 检查 Registry 是否存在
|
// 检查 Registry 是否存在
|
||||||
registry, err := s.registryRepo.GetByID(ctx, instance.RegistryID)
|
registry, err := s.registryRepo.GetByID(ctx, instance.RegistryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
if !authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||||
|
return entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
if err := s.applyNamespacePolicy(ctx, principal, cluster, instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
enforceNamespaceValues(instance)
|
||||||
|
if err := s.ensureTenantForInstance(ctx, principal, cluster, instance); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// 检查实例是否已存在
|
// 检查实例是否已存在
|
||||||
existingInstance, _ := s.instanceRepo.GetByClusterAndName(ctx, instance.ClusterID, instance.Name)
|
existingInstance, _ := s.instanceRepo.GetByClusterAndName(ctx, instance.ClusterID, instance.Name)
|
||||||
@ -111,13 +164,24 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
|
|||||||
|
|
||||||
// GetInstance 获取实例
|
// GetInstance 获取实例
|
||||||
func (s *InstanceService) GetInstance(ctx context.Context, id string) (*entity.Instance, error) {
|
func (s *InstanceService) GetInstance(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
return s.instanceRepo.GetByID(ctx, id)
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !s.canReadInstance(principal, instance) {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
return instance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInstanceStatus 获取实例实时状态
|
// GetInstanceStatus 获取实例实时状态
|
||||||
func (s *InstanceService) GetInstanceStatus(ctx context.Context, id string) (*entity.Instance, error) {
|
func (s *InstanceService) GetInstanceStatus(ctx context.Context, id string) (*entity.Instance, error) {
|
||||||
// 从数据库获取基本信息
|
// 从数据库获取基本信息
|
||||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
instance, err := s.GetInstance(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, entity.ErrInstanceNotFound
|
return nil, entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
@ -143,11 +207,20 @@ func (s *InstanceService) GetInstanceStatus(ctx context.Context, id string) (*en
|
|||||||
|
|
||||||
// UpdateInstance 更新(升级)实例
|
// UpdateInstance 更新(升级)实例
|
||||||
func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.Instance) error {
|
func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查实例是否存在
|
// 检查实例是否存在
|
||||||
existingInstance, err := s.instanceRepo.GetByID(ctx, instance.ID)
|
existingInstance, err := s.instanceRepo.GetByID(ctx, instance.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrInstanceNotFound
|
return entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
if !s.canWriteInstance(principal, existingInstance) {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
instance.WorkspaceID = existingInstance.WorkspaceID
|
||||||
|
instance.OwnerID = existingInstance.OwnerID
|
||||||
|
|
||||||
// 获取集群信息
|
// 获取集群信息
|
||||||
cluster, err := s.clusterRepo.GetByID(ctx, existingInstance.ClusterID)
|
cluster, err := s.clusterRepo.GetByID(ctx, existingInstance.ClusterID)
|
||||||
@ -161,6 +234,8 @@ func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.I
|
|||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
instance.Namespace = existingInstance.Namespace
|
||||||
|
enforceNamespaceValues(instance)
|
||||||
instance.BeginOperation(entity.OperationUpgrade, "Pending upgrade")
|
instance.BeginOperation(entity.OperationUpgrade, "Pending upgrade")
|
||||||
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -182,11 +257,18 @@ func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.I
|
|||||||
|
|
||||||
// DeleteInstance 删除(卸载)实例
|
// DeleteInstance 删除(卸载)实例
|
||||||
func (s *InstanceService) DeleteInstance(ctx context.Context, id string) error {
|
func (s *InstanceService) DeleteInstance(ctx context.Context, id string) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查实例是否存在
|
// 检查实例是否存在
|
||||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrInstanceNotFound
|
return entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
if !s.canWriteInstance(principal, instance) {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
// 获取集群信息
|
// 获取集群信息
|
||||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||||
@ -208,11 +290,18 @@ func (s *InstanceService) DeleteInstance(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
// RollbackInstance 回滚实例
|
// RollbackInstance 回滚实例
|
||||||
func (s *InstanceService) RollbackInstance(ctx context.Context, id string, revision int) error {
|
func (s *InstanceService) RollbackInstance(ctx context.Context, id string, revision int) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查实例是否存在
|
// 检查实例是否存在
|
||||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrInstanceNotFound
|
return entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
|
if !s.canWriteInstance(principal, instance) {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
// 获取集群信息
|
// 获取集群信息
|
||||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||||
@ -235,7 +324,7 @@ func (s *InstanceService) RollbackInstance(ctx context.Context, id string, revis
|
|||||||
// GetInstanceHistory 获取实例历史
|
// GetInstanceHistory 获取实例历史
|
||||||
func (s *InstanceService) GetInstanceHistory(ctx context.Context, id string) ([]*entity.ReleaseHistory, error) {
|
func (s *InstanceService) GetInstanceHistory(ctx context.Context, id string) ([]*entity.ReleaseHistory, error) {
|
||||||
// 检查实例是否存在
|
// 检查实例是否存在
|
||||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
instance, err := s.GetInstance(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, entity.ErrInstanceNotFound
|
return nil, entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
@ -252,18 +341,35 @@ func (s *InstanceService) GetInstanceHistory(ctx context.Context, id string) ([]
|
|||||||
|
|
||||||
// ListInstancesByCluster 列出集群的所有实例
|
// ListInstancesByCluster 列出集群的所有实例
|
||||||
func (s *InstanceService) ListInstancesByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
func (s *InstanceService) ListInstancesByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查集群是否存在
|
// 检查集群是否存在
|
||||||
_, err := s.clusterRepo.GetByID(ctx, clusterID)
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, entity.ErrClusterNotFound
|
return nil, entity.ErrClusterNotFound
|
||||||
}
|
}
|
||||||
|
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
return s.instanceRepo.ListByCluster(ctx, clusterID)
|
instances, err := s.instanceRepo.ListByCluster(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
visible := make([]*entity.Instance, 0, len(instances))
|
||||||
|
for _, instance := range instances {
|
||||||
|
if s.canReadInstance(principal, instance) {
|
||||||
|
visible = append(visible, instance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInstanceEntries 列出实例关联的入口信息(Service / Ingress)
|
// ListInstanceEntries 列出实例关联的入口信息(Service / Ingress)
|
||||||
func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, instanceID string) ([]*entity.InstanceEntry, error) {
|
func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, instanceID string) ([]*entity.InstanceEntry, error) {
|
||||||
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
instance, err := s.GetInstance(ctx, instanceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, entity.ErrInstanceNotFound
|
return nil, entity.ErrInstanceNotFound
|
||||||
}
|
}
|
||||||
@ -283,6 +389,322 @@ func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, in
|
|||||||
return s.entryClient.ListEntries(ctx, cluster, instance)
|
return s.entryClient.ListEntries(ctx, cluster, instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) GetInstanceDiagnostics(ctx context.Context, clusterID, instanceID string, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||||
|
instance, err := s.GetInstance(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.diagClient == nil {
|
||||||
|
return nil, fmt.Errorf("instance diagnostics client is not configured")
|
||||||
|
}
|
||||||
|
return s.diagClient.GetDiagnostics(ctx, cluster, instance, tailLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) StreamInstanceLogs(ctx context.Context, clusterID, instanceID, podName, containerName string, tailLines int64) (<-chan string, <-chan error, error) {
|
||||||
|
instance, err := s.GetInstance(ctx, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
if instance.ClusterID != clusterID {
|
||||||
|
return nil, nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
if s.diagClient == nil {
|
||||||
|
return nil, nil, fmt.Errorf("instance diagnostics client is not configured")
|
||||||
|
}
|
||||||
|
streamer, ok := s.diagClient.(repository.PodLogStreamer)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("diagnostics client does not support log streaming")
|
||||||
|
}
|
||||||
|
return streamer.StreamPodLogs(ctx, cluster, instance.Namespace, podName, containerName, tailLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleInstance 扩缩容实例(修改 replicaCount 后执行 Helm upgrade)
|
||||||
|
func (s *InstanceService) ScaleInstance(ctx context.Context, clusterID, instanceID string, replicas int, workload string) (*entity.Instance, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
if !s.canWriteInstance(principal, instance) {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale via K8s API directly (like kubectl scale deploy --replicas=N)
|
||||||
|
if s.scaleClient != nil {
|
||||||
|
if err := s.scaleClient.ScaleDeployment(ctx, cluster, instance.Namespace, instance.Name, int32(replicas)); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scale deployment: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: Helm upgrade with replicaCount
|
||||||
|
vals, err := s.helmClient.GetValues(ctx, cluster, instance.Name, instance.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get current values: %w", err)
|
||||||
|
}
|
||||||
|
if vals == nil {
|
||||||
|
vals = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
vals["replicaCount"] = replicas
|
||||||
|
instance.SetValues(vals)
|
||||||
|
instance.BeginOperation(entity.OperationUpgrade, fmt.Sprintf("Scaling to %d replicas", replicas))
|
||||||
|
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
go s.executeAndSyncUpgrade(context.Background(), instance.ID, cluster, nil, instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichReplicas 批量获取实例的 K8s 实际副本数并设置到 entity 上
|
||||||
|
func (s *InstanceService) EnrichReplicas(ctx context.Context, clusterID string, instances []*entity.Instance) []*entity.Instance {
|
||||||
|
if s.scaleClient == nil || len(instances) == 0 {
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
for _, inst := range instances {
|
||||||
|
r, err := s.scaleClient.GetDeploymentReplicas(ctx, cluster, inst.Namespace, inst.Name)
|
||||||
|
if err == nil {
|
||||||
|
inst.Replicas = int(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return instances
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRunningReplicas returns the actual K8s deployment replicas count.
|
||||||
|
func (s *InstanceService) GetRunningReplicas(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) int {
|
||||||
|
if s.scaleClient == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
r, err := s.scaleClient.GetDeploymentReplicas(ctx, cluster, instance.Namespace, instance.Name)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstanceValuesDiff 获取实例当前 values 与 chart 默认 values 的差异
|
||||||
|
func (s *InstanceService) GetInstanceValuesDiff(ctx context.Context, clusterID, instanceID string) (*dto.InstanceValuesDiffResponse, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
if !s.canReadInstance(principal, instance) {
|
||||||
|
return nil, entity.ErrInstanceNotFound
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.helmClient.GetValues(ctx, cluster, instance.Name, instance.Namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default values from the chart archive
|
||||||
|
chartPath := s.chartArchivePath(instance)
|
||||||
|
defaults, err := s.helmClient.GetChartDefaultValues(chartPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read chart defaults: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.InstanceValuesDiffResponse{
|
||||||
|
Current: current,
|
||||||
|
Defaults: defaults,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) canReadInstance(principal *authz.Principal, instance *entity.Instance) bool {
|
||||||
|
if principal.IsAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return instance.WorkspaceID == principal.WorkspaceID && instance.OwnerID == principal.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) canWriteInstance(principal *authz.Principal, instance *entity.Instance) bool {
|
||||||
|
if principal.IsAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return instance.WorkspaceID == principal.WorkspaceID && instance.OwnerID == principal.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceNamespaceValues(instance *entity.Instance) {
|
||||||
|
if instance == nil || instance.Namespace == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if instance.Values == nil {
|
||||||
|
instance.Values = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
instance.Values["namespace"] = instance.Namespace
|
||||||
|
setExistingStringValue(instance.Values, "namespaceOverride", instance.Namespace)
|
||||||
|
setExistingStringValue(instance.Values, "namespace_override", instance.Namespace)
|
||||||
|
setExistingStringValue(instance.Values, "targetNamespace", instance.Namespace)
|
||||||
|
setExistingStringValue(instance.Values, "target_namespace", instance.Namespace)
|
||||||
|
setExistingNestedStringValue(instance.Values, "global", "namespace", instance.Namespace)
|
||||||
|
setExistingNestedStringValue(instance.Values, "global", "namespaceOverride", instance.Namespace)
|
||||||
|
setExistingNestedStringValue(instance.Values, "global", "namespace_override", instance.Namespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExistingStringValue(values map[string]interface{}, key, namespace string) {
|
||||||
|
if _, ok := values[key]; ok {
|
||||||
|
values[key] = namespace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setExistingNestedStringValue(values map[string]interface{}, parent, key, namespace string) {
|
||||||
|
child, ok := values[parent].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := child[key]; ok {
|
||||||
|
child[key] = namespace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) applyNamespacePolicy(ctx context.Context, principal *authz.Principal, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
if principal.IsAdmin() {
|
||||||
|
if isProtectedSystemNamespace(instance.Namespace) {
|
||||||
|
return entity.ErrInvalidNamespace
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isReservedNamespace(instance.Namespace) {
|
||||||
|
return entity.ErrInvalidNamespace
|
||||||
|
}
|
||||||
|
if cluster.Visibility != authz.VisibilityPrivate || cluster.OwnerID != principal.UserID {
|
||||||
|
namespace := principal.Namespace
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = entity.NamespaceForWorkspace(principal.WorkspaceName)
|
||||||
|
}
|
||||||
|
if s.bindingRepo != nil {
|
||||||
|
if binding, err := s.bindingRepo.Get(ctx, principal.WorkspaceID, cluster.ID); err == nil && binding != nil && binding.Namespace != "" {
|
||||||
|
namespace = binding.Namespace
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instance.Namespace = namespace
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if instance.Namespace == "" {
|
||||||
|
if cluster.DefaultNamespace != "" {
|
||||||
|
instance.Namespace = cluster.DefaultNamespace
|
||||||
|
} else if principal.Namespace != "" {
|
||||||
|
instance.Namespace = principal.Namespace
|
||||||
|
} else {
|
||||||
|
instance.Namespace = entity.NamespaceForWorkspace(principal.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InstanceService) ensureTenantForInstance(ctx context.Context, principal *authz.Principal, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||||
|
if principal.IsAdmin() || s.workspaceRepo == nil || s.tenantClient == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, principal.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if workspace.Status == entity.WorkspaceSuspended {
|
||||||
|
return entity.ErrWorkspaceSuspended
|
||||||
|
}
|
||||||
|
binding := entity.NewTenantBinding(instance.Namespace)
|
||||||
|
binding.ServiceAccountName = workspace.K8sSAName
|
||||||
|
binding.ResourceQuotaHard = instanceResourceQuotaHard(workspace)
|
||||||
|
if err := s.tenantClient.EnsureTenant(ctx, cluster, binding); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.bindingRepo != nil {
|
||||||
|
_ = s.bindingRepo.Upsert(ctx, &entity.WorkspaceClusterBinding{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
WorkspaceID: workspace.ID,
|
||||||
|
ClusterID: cluster.ID,
|
||||||
|
Namespace: instance.Namespace,
|
||||||
|
ServiceAccount: workspace.K8sSAName,
|
||||||
|
QuotaCPU: workspace.QuotaCPU,
|
||||||
|
QuotaMemory: workspace.QuotaMemory,
|
||||||
|
QuotaGPU: workspace.QuotaGPU,
|
||||||
|
QuotaGPUMem: workspace.QuotaGPUMem,
|
||||||
|
Status: "active",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func instanceResourceQuotaHard(workspace *entity.Workspace) corev1.ResourceList {
|
||||||
|
hard := corev1.ResourceList{}
|
||||||
|
addQuantity := func(name corev1.ResourceName, value string) {
|
||||||
|
value = normalizeStandardQuotaQuantity(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if quantity, err := resource.ParseQuantity(value); err == nil {
|
||||||
|
hard[name] = quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addGPUMemoryQuantity := func(value string) {
|
||||||
|
value, err := normalizeGPUMemoryQuota(value)
|
||||||
|
if err != nil || value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if quantity, err := resource.ParseQuantity(value); err == nil {
|
||||||
|
hard[corev1.ResourceName("requests.nvidia.com/gpumem")] = quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if workspace == nil {
|
||||||
|
return hard
|
||||||
|
}
|
||||||
|
addQuantity(corev1.ResourceName("requests.cpu"), workspace.QuotaCPU)
|
||||||
|
addQuantity(corev1.ResourceName("requests.memory"), workspace.QuotaMemory)
|
||||||
|
addQuantity(corev1.ResourceName("requests.nvidia.com/gpu"), workspace.QuotaGPU)
|
||||||
|
addGPUMemoryQuantity(workspace.QuotaGPUMem)
|
||||||
|
return hard
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReservedNamespace(namespace string) bool {
|
||||||
|
switch namespace {
|
||||||
|
case "default", "kube-system", "kube-public", "kube-node-lease":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProtectedSystemNamespace(namespace string) bool {
|
||||||
|
switch namespace {
|
||||||
|
case "kube-system", "kube-public", "kube-node-lease":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// executeAndSyncInstall 异步执行安装并监控状态
|
// executeAndSyncInstall 异步执行安装并监控状态
|
||||||
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
||||||
// 执行 Helm 安装
|
// 执行 Helm 安装
|
||||||
@ -338,7 +760,7 @@ func (s *InstanceService) executeAndSyncRollback(ctx context.Context, instanceID
|
|||||||
func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) {
|
func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) {
|
||||||
// 执行 Helm 卸载
|
// 执行 Helm 卸载
|
||||||
err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace)
|
err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace)
|
||||||
|
|
||||||
// 获取实例
|
// 获取实例
|
||||||
instance, getErr := s.instanceRepo.GetByID(ctx, instanceID)
|
instance, getErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
if getErr != nil {
|
if getErr != nil {
|
||||||
@ -360,7 +782,7 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
|
|||||||
// 卸载成功,标记为已卸载
|
// 卸载成功,标记为已卸载
|
||||||
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully")
|
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully")
|
||||||
_ = s.instanceRepo.Update(ctx, instance)
|
_ = s.instanceRepo.Update(ctx, instance)
|
||||||
|
|
||||||
// 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载
|
// 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
_, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
|
_, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
|
||||||
@ -377,7 +799,7 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
|
|||||||
|
|
||||||
// syncInstanceStatus 同步实例状态(定期检查 Helm 状态并更新数据库)
|
// syncInstanceStatus 同步实例状态(定期检查 Helm 状态并更新数据库)
|
||||||
func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string, operation entity.InstanceOperation) {
|
func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string, operation entity.InstanceOperation) {
|
||||||
maxAttempts := 30 // 最多尝试30次(约5分钟)
|
maxAttempts := 30 // 最多尝试30次(约5分钟)
|
||||||
interval := 10 * time.Second // 每10秒检查一次
|
interval := 10 * time.Second // 每10秒检查一次
|
||||||
|
|
||||||
for i := 0; i < maxAttempts; i++ {
|
for i := 0; i < maxAttempts; i++ {
|
||||||
|
|||||||
@ -4,21 +4,26 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
persistencemock "github.com/ocdp/cluster-service/internal/adapter/output/persistence/mock"
|
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/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDeleteInstanceIgnoresMissingRelease(t *testing.T) {
|
func TestDeleteInstanceIgnoresMissingRelease(t *testing.T) {
|
||||||
ctx := context.Background()
|
principal := &authz.Principal{UserID: "user-1", Username: "tester", Role: authz.RoleUser, WorkspaceID: entity.DefaultWorkspaceID}
|
||||||
|
ctx := authz.WithPrincipal(context.Background(), principal)
|
||||||
instanceRepo := persistencemock.NewInstanceRepositoryMock()
|
instanceRepo := persistencemock.NewInstanceRepositoryMock()
|
||||||
|
|
||||||
instance := &entity.Instance{
|
instance := &entity.Instance{
|
||||||
ID: "inst-1",
|
ID: "inst-1",
|
||||||
ClusterID: "cluster-1",
|
WorkspaceID: entity.DefaultWorkspaceID,
|
||||||
Name: "demo",
|
OwnerID: "user-1",
|
||||||
Namespace: "default",
|
ClusterID: "cluster-1",
|
||||||
|
Name: "demo",
|
||||||
|
Namespace: "default",
|
||||||
}
|
}
|
||||||
if err := instanceRepo.Create(ctx, instance); err != nil {
|
if err := instanceRepo.Create(ctx, instance); err != nil {
|
||||||
t.Fatalf("failed to seed instance: %v", err)
|
t.Fatalf("failed to seed instance: %v", err)
|
||||||
@ -40,8 +45,63 @@ func TestDeleteInstanceIgnoresMissingRelease(t *testing.T) {
|
|||||||
t.Fatalf("DeleteInstance returned error: %v", err)
|
t.Fatalf("DeleteInstance returned error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := instanceRepo.GetByID(ctx, instance.ID); !errors.Is(err, entity.ErrInstanceNotFound) {
|
waitForInstanceDeleted(t, ctx, instanceRepo, instance.ID)
|
||||||
t.Fatalf("expected instance removed, got err=%v", err)
|
}
|
||||||
|
|
||||||
|
func TestEnforceNamespaceValuesOverridesChartNamespaceKnobs(t *testing.T) {
|
||||||
|
instance := &entity.Instance{
|
||||||
|
Namespace: "ocdp-u-alice",
|
||||||
|
Values: map[string]interface{}{
|
||||||
|
"namespace": "default",
|
||||||
|
"namespaceOverride": "default",
|
||||||
|
"targetNamespace": "default",
|
||||||
|
"global": map[string]interface{}{
|
||||||
|
"namespace": "default",
|
||||||
|
"namespaceOverride": "default",
|
||||||
|
},
|
||||||
|
"image": map[string]interface{}{
|
||||||
|
"repository": "nginx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
enforceNamespaceValues(instance)
|
||||||
|
|
||||||
|
if instance.Values["namespace"] != "ocdp-u-alice" {
|
||||||
|
t.Fatalf("expected top-level namespace to be enforced, got %#v", instance.Values["namespace"])
|
||||||
|
}
|
||||||
|
if instance.Values["namespaceOverride"] != "ocdp-u-alice" {
|
||||||
|
t.Fatalf("expected namespaceOverride to be enforced, got %#v", instance.Values["namespaceOverride"])
|
||||||
|
}
|
||||||
|
if instance.Values["targetNamespace"] != "ocdp-u-alice" {
|
||||||
|
t.Fatalf("expected targetNamespace to be enforced, got %#v", instance.Values["targetNamespace"])
|
||||||
|
}
|
||||||
|
global, ok := instance.Values["global"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected global map, got %#v", instance.Values["global"])
|
||||||
|
}
|
||||||
|
if global["namespace"] != "ocdp-u-alice" || global["namespaceOverride"] != "ocdp-u-alice" {
|
||||||
|
t.Fatalf("expected global namespace keys to be enforced, got %#v", global)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitForInstanceDeleted(t *testing.T, ctx context.Context, repo repository.InstanceRepository, id string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deadline := time.After(2 * time.Second)
|
||||||
|
ticker := time.NewTicker(10 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
_, err := repo.GetByID(ctx, id)
|
||||||
|
t.Fatalf("expected instance removed, got err=%v", err)
|
||||||
|
case <-ticker.C:
|
||||||
|
if _, err := repo.GetByID(ctx, id); errors.Is(err, entity.ErrInstanceNotFound) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,5 +167,9 @@ func (*stubHelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, r
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (*stubHelmClient) GetChartDefaultValues(chartPath string) (map[string]interface{}, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
var _ repository.ClusterRepository = (*stubClusterRepo)(nil)
|
var _ repository.ClusterRepository = (*stubClusterRepo)(nil)
|
||||||
var _ repository.HelmClient = (*stubHelmClient)(nil)
|
var _ repository.HelmClient = (*stubHelmClient)(nil)
|
||||||
|
|||||||
@ -6,11 +6,12 @@ import (
|
|||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MonitoringService 监控服务
|
// MonitoringService 监控服务
|
||||||
type MonitoringService struct {
|
type MonitoringService struct {
|
||||||
clusterRepo repository.ClusterRepository
|
clusterRepo repository.ClusterRepository
|
||||||
metricsClient repository.MetricsClient
|
metricsClient repository.MetricsClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,13 +21,24 @@ func NewMonitoringService(
|
|||||||
metricsClient repository.MetricsClient,
|
metricsClient repository.MetricsClient,
|
||||||
) *MonitoringService {
|
) *MonitoringService {
|
||||||
return &MonitoringService{
|
return &MonitoringService{
|
||||||
clusterRepo: clusterRepo,
|
clusterRepo: clusterRepo,
|
||||||
metricsClient: metricsClient,
|
metricsClient: metricsClient,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClusterMonitoring 获取单个集群的监控信息
|
// GetClusterMonitoring 获取单个集群的监控信息
|
||||||
func (s *MonitoringService) GetClusterMonitoring(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error) {
|
func (s *MonitoringService) GetClusterMonitoring(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
metrics, err := s.metricsClient.GetClusterMetrics(ctx, clusterID)
|
metrics, err := s.metricsClient.GetClusterMetrics(ctx, clusterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get cluster metrics: %w", err)
|
return nil, fmt.Errorf("failed to get cluster metrics: %w", err)
|
||||||
@ -36,6 +48,10 @@ func (s *MonitoringService) GetClusterMonitoring(ctx context.Context, clusterID
|
|||||||
|
|
||||||
// ListClusterMonitoring 获取所有集群的监控信息
|
// ListClusterMonitoring 获取所有集群的监控信息
|
||||||
func (s *MonitoringService) ListClusterMonitoring(ctx context.Context) ([]*entity.ClusterMetrics, error) {
|
func (s *MonitoringService) ListClusterMonitoring(ctx context.Context) ([]*entity.ClusterMetrics, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 获取所有集群
|
// 获取所有集群
|
||||||
clusters, err := s.clusterRepo.List(ctx)
|
clusters, err := s.clusterRepo.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -45,6 +61,9 @@ func (s *MonitoringService) ListClusterMonitoring(ctx context.Context) ([]*entit
|
|||||||
// 获取每个集群的监控数据
|
// 获取每个集群的监控数据
|
||||||
result := make([]*entity.ClusterMetrics, 0, len(clusters))
|
result := make([]*entity.ClusterMetrics, 0, len(clusters))
|
||||||
for _, cluster := range clusters {
|
for _, cluster := range clusters {
|
||||||
|
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
metrics, err := s.metricsClient.GetClusterMetrics(ctx, cluster.ID)
|
metrics, err := s.metricsClient.GetClusterMetrics(ctx, cluster.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 如果某个集群获取失败,记录错误但继续
|
// 如果某个集群获取失败,记录错误但继续
|
||||||
@ -93,10 +112,20 @@ func (s *MonitoringService) GetMonitoringSummary(ctx context.Context) (*entity.M
|
|||||||
|
|
||||||
// GetNodeMetrics 获取集群的节点指标
|
// GetNodeMetrics 获取集群的节点指标
|
||||||
func (s *MonitoringService) GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error) {
|
func (s *MonitoringService) GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
nodes, err := s.metricsClient.GetNodeMetrics(ctx, clusterID)
|
nodes, err := s.metricsClient.GetNodeMetrics(ctx, clusterID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get node metrics: %w", err)
|
return nil, fmt.Errorf("failed to get node metrics: %w", err)
|
||||||
}
|
}
|
||||||
return nodes, nil
|
return nodes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
backend/internal/domain/service/quota_quantity.go
Normal file
54
backend/internal/domain/service/quota_quantity.go
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeStandardQuotaQuantity(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
upper := strings.ToUpper(value)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(upper, "MB"):
|
||||||
|
return strings.TrimSpace(value[:len(value)-2]) + "M"
|
||||||
|
case strings.HasSuffix(upper, "GB"):
|
||||||
|
return strings.TrimSpace(value[:len(value)-2]) + "G"
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeGPUMemoryQuota(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
upper := strings.ToUpper(value)
|
||||||
|
multiplier := int64(1)
|
||||||
|
number := value
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(upper, "MB"):
|
||||||
|
number = strings.TrimSpace(value[:len(value)-2])
|
||||||
|
case strings.HasSuffix(upper, "M"):
|
||||||
|
number = strings.TrimSpace(value[:len(value)-1])
|
||||||
|
case strings.HasSuffix(upper, "GB"):
|
||||||
|
number = strings.TrimSpace(value[:len(value)-2])
|
||||||
|
multiplier = 1000
|
||||||
|
case strings.HasSuffix(upper, "G"):
|
||||||
|
number = strings.TrimSpace(value[:len(value)-1])
|
||||||
|
multiplier = 1000
|
||||||
|
case strings.HasSuffix(upper, "GIB"):
|
||||||
|
number = strings.TrimSpace(value[:len(value)-3])
|
||||||
|
multiplier = 1024
|
||||||
|
case strings.HasSuffix(upper, "GI"):
|
||||||
|
number = strings.TrimSpace(value[:len(value)-2])
|
||||||
|
multiplier = 1024
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseInt(number, 10, 64)
|
||||||
|
if err != nil || parsed < 0 {
|
||||||
|
return "", entity.ErrInvalidTenantResourceQuota
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(parsed*multiplier, 10), nil
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
|
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegistryService Registry 管理领域服务
|
// RegistryService Registry 管理领域服务
|
||||||
@ -26,8 +27,21 @@ func NewRegistryService(
|
|||||||
|
|
||||||
// CreateRegistry 创建新 Registry
|
// CreateRegistry 创建新 Registry
|
||||||
func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.Registry) error {
|
func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 生成 ID
|
// 生成 ID
|
||||||
registry.ID = uuid.New().String()
|
registry.ID = uuid.New().String()
|
||||||
|
registry.OwnerID = principal.UserID
|
||||||
|
registry.WorkspaceID = principal.WorkspaceID
|
||||||
|
if principal.IsAdmin() && registry.WorkspaceID == "" {
|
||||||
|
registry.WorkspaceID = entity.DefaultWorkspaceID
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() && registry.Visibility == authz.VisibilityGlobalShared {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
registry.Visibility = authz.NormalizeVisibility(principal.Role, registry.Visibility)
|
||||||
|
|
||||||
// 验证
|
// 验证
|
||||||
if err := registry.Validate(); err != nil {
|
if err := registry.Validate(); err != nil {
|
||||||
@ -35,9 +49,11 @@ func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.R
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已存在
|
// 检查是否已存在
|
||||||
existingRegistry, _ := s.registryRepo.GetByName(ctx, registry.Name)
|
registries, _ := s.registryRepo.List(ctx)
|
||||||
if existingRegistry != nil {
|
for _, existingRegistry := range registries {
|
||||||
return entity.ErrRegistryExists
|
if existingRegistry.Name == registry.Name && existingRegistry.WorkspaceID == registry.WorkspaceID && existingRegistry.OwnerID == registry.OwnerID {
|
||||||
|
return entity.ErrRegistryExists
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.registryRepo.Create(ctx, registry)
|
return s.registryRepo.Create(ctx, registry)
|
||||||
@ -45,16 +61,41 @@ func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.R
|
|||||||
|
|
||||||
// GetRegistry 获取 Registry
|
// GetRegistry 获取 Registry
|
||||||
func (s *RegistryService) GetRegistry(ctx context.Context, id string) (*entity.Registry, error) {
|
func (s *RegistryService) GetRegistry(ctx context.Context, id string) (*entity.Registry, error) {
|
||||||
return s.registryRepo.GetByID(ctx, id)
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
registry, err := s.registryRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||||
|
return nil, entity.ErrRegistryNotFound
|
||||||
|
}
|
||||||
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRegistry 更新 Registry
|
// UpdateRegistry 更新 Registry
|
||||||
func (s *RegistryService) UpdateRegistry(ctx context.Context, registry *entity.Registry) error {
|
func (s *RegistryService) UpdateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查是否存在
|
// 检查是否存在
|
||||||
_, err := s.registryRepo.GetByID(ctx, registry.ID)
|
existing, err := s.registryRepo.GetByID(ctx, registry.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
if !authz.CanWriteResource(principal, existing.WorkspaceID, existing.OwnerID, existing.Visibility) {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
registry.WorkspaceID = existing.WorkspaceID
|
||||||
|
registry.OwnerID = existing.OwnerID
|
||||||
|
if principal.IsAdmin() {
|
||||||
|
registry.Visibility = authz.NormalizeVisibility(principal.Role, registry.Visibility)
|
||||||
|
} else {
|
||||||
|
registry.Visibility = existing.Visibility
|
||||||
|
}
|
||||||
|
|
||||||
// 验证
|
// 验证
|
||||||
if err := registry.Validate(); err != nil {
|
if err := registry.Validate(); err != nil {
|
||||||
@ -66,27 +107,47 @@ func (s *RegistryService) UpdateRegistry(ctx context.Context, registry *entity.R
|
|||||||
|
|
||||||
// DeleteRegistry 删除 Registry
|
// DeleteRegistry 删除 Registry
|
||||||
func (s *RegistryService) DeleteRegistry(ctx context.Context, id string) error {
|
func (s *RegistryService) DeleteRegistry(ctx context.Context, id string) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
// 检查是否存在
|
// 检查是否存在
|
||||||
_, err := s.registryRepo.GetByID(ctx, id)
|
registry, err := s.registryRepo.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
if !authz.CanWriteResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
return s.registryRepo.Delete(ctx, id)
|
return s.registryRepo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRegistries 列出所有 Registries
|
// ListRegistries 列出所有 Registries
|
||||||
func (s *RegistryService) ListRegistries(ctx context.Context) ([]*entity.Registry, error) {
|
func (s *RegistryService) ListRegistries(ctx context.Context) ([]*entity.Registry, error) {
|
||||||
return s.registryRepo.List(ctx)
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
registries, err := s.registryRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
visible := make([]*entity.Registry, 0, len(registries))
|
||||||
|
for _, registry := range registries {
|
||||||
|
if authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||||
|
visible = append(visible, registry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visible, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckHealth 检查 Registry 健康状态
|
// CheckHealth 检查 Registry 健康状态
|
||||||
func (s *RegistryService) CheckHealth(ctx context.Context, id string) error {
|
func (s *RegistryService) CheckHealth(ctx context.Context, id string) error {
|
||||||
registry, err := s.registryRepo.GetByID(ctx, id)
|
registry, err := s.GetRegistry(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entity.ErrRegistryNotFound
|
return entity.ErrRegistryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.ociClient.CheckHealth(ctx, registry)
|
return s.ociClient.CheckHealth(ctx, registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
308
backend/internal/domain/service/workspace_service.go
Normal file
308
backend/internal/domain/service/workspace_service.go
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"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/authz"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WorkspaceService struct {
|
||||||
|
workspaceRepo repository.WorkspaceRepository
|
||||||
|
bindingRepo repository.WorkspaceClusterBindingRepository
|
||||||
|
clusterRepo repository.ClusterRepository
|
||||||
|
tenantClient repository.TenantKubeClient
|
||||||
|
auditRepo repository.AuditLogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWorkspaceService(
|
||||||
|
workspaceRepo repository.WorkspaceRepository,
|
||||||
|
bindingRepo repository.WorkspaceClusterBindingRepository,
|
||||||
|
clusterRepo repository.ClusterRepository,
|
||||||
|
tenantClient repository.TenantKubeClient,
|
||||||
|
auditRepo repository.AuditLogRepository,
|
||||||
|
) *WorkspaceService {
|
||||||
|
return &WorkspaceService{
|
||||||
|
workspaceRepo: workspaceRepo,
|
||||||
|
bindingRepo: bindingRepo,
|
||||||
|
clusterRepo: clusterRepo,
|
||||||
|
tenantClient: tenantClient,
|
||||||
|
auditRepo: auditRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) ListWorkspaces(ctx context.Context) ([]*entity.Workspace, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if principal.IsAdmin() {
|
||||||
|
return s.workspaceRepo.List(ctx)
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, principal.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []*entity.Workspace{workspace}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) CreateWorkspace(ctx context.Context, name string) (*entity.Workspace, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
workspace := entity.NewWorkspace(name, principal.UserID)
|
||||||
|
workspace.ID = uuid.New().String()
|
||||||
|
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.audit(ctx, principal, "create", "workspace", workspace.ID, workspace.Name, nil)
|
||||||
|
return workspace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) EnsureClusterBinding(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() && workspaceID != principal.WorkspaceID {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() && !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
binding := &entity.WorkspaceClusterBinding{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
WorkspaceID: workspace.ID,
|
||||||
|
ClusterID: cluster.ID,
|
||||||
|
Namespace: workspace.K8sNamespace,
|
||||||
|
ServiceAccount: workspace.K8sSAName,
|
||||||
|
QuotaCPU: workspace.QuotaCPU,
|
||||||
|
QuotaMemory: workspace.QuotaMemory,
|
||||||
|
QuotaGPU: workspace.QuotaGPU,
|
||||||
|
QuotaGPUMem: workspace.QuotaGPUMem,
|
||||||
|
Status: "active",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||||
|
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||||
|
tenantBinding.ResourceQuotaHard = resourceQuotaHard(workspace)
|
||||||
|
if s.tenantClient != nil {
|
||||||
|
if err := s.tenantClient.EnsureTenant(ctx, cluster, tenantBinding); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.bindingRepo.Upsert(ctx, binding); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.audit(ctx, principal, "init", "workspace_cluster_binding", binding.ID, binding.Namespace, map[string]interface{}{"cluster_id": clusterID})
|
||||||
|
return binding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) IssueKubeconfig(ctx context.Context, workspaceID, clusterID string, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() && workspaceID != principal.WorkspaceID {
|
||||||
|
return nil, entity.ErrForbidden
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if workspace.Status == entity.WorkspaceSuspended {
|
||||||
|
return nil, entity.ErrWorkspaceSuspended
|
||||||
|
}
|
||||||
|
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() && !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
binding, err := s.bindingRepo.Get(ctx, workspaceID, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
binding, err = s.EnsureClusterBinding(ctx, workspaceID, clusterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||||
|
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||||
|
tenantBinding.ResourceQuotaHard = resourceQuotaHard(workspace)
|
||||||
|
kubeconfig, err := s.tenantClient.IssueKubeconfig(ctx, cluster, tenantBinding, ttl)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.audit(ctx, principal, "issue_kubeconfig", "workspace_cluster_binding", binding.ID, binding.Namespace, map[string]interface{}{"cluster_id": clusterID, "ttl_seconds": int64(entity.TenantTokenTTL(ttl).Seconds())})
|
||||||
|
return kubeconfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourceQuotaHard(workspace *entity.Workspace) corev1.ResourceList {
|
||||||
|
hard := corev1.ResourceList{}
|
||||||
|
addQuantity := func(name corev1.ResourceName, value string) {
|
||||||
|
value = normalizeStandardQuotaQuantity(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if quantity, err := resource.ParseQuantity(value); err == nil {
|
||||||
|
hard[name] = quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addGPUMemoryQuantity := func(value string) {
|
||||||
|
value, err := normalizeGPUMemoryQuota(value)
|
||||||
|
if err != nil || value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if quantity, err := resource.ParseQuantity(value); err == nil {
|
||||||
|
hard[corev1.ResourceName("requests.nvidia.com/gpumem")] = quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if workspace == nil {
|
||||||
|
return hard
|
||||||
|
}
|
||||||
|
addQuantity(corev1.ResourceName("requests.cpu"), workspace.QuotaCPU)
|
||||||
|
addQuantity(corev1.ResourceName("requests.memory"), workspace.QuotaMemory)
|
||||||
|
addQuantity(corev1.ResourceName("requests.nvidia.com/gpu"), workspace.QuotaGPU)
|
||||||
|
addGPUMemoryQuantity(workspace.QuotaGPUMem)
|
||||||
|
return hard
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) IssueCurrentKubeconfig(ctx context.Context, requestedClusterID string, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if requestedClusterID != "" {
|
||||||
|
return s.IssueKubeconfig(ctx, principal.WorkspaceID, requestedClusterID, ttl)
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, principal.WorkspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if workspace.DefaultClusterID != "" {
|
||||||
|
return s.IssueKubeconfig(ctx, principal.WorkspaceID, workspace.DefaultClusterID, ttl)
|
||||||
|
}
|
||||||
|
return s.IssueDefaultKubeconfig(ctx, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) IssueDefaultKubeconfig(ctx context.Context, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
clusters, err := s.clusterRepo.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidates := make([]*entity.Cluster, 0, len(clusters))
|
||||||
|
for _, cluster := range clusters {
|
||||||
|
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch cluster.Visibility {
|
||||||
|
case authz.VisibilityGlobalShared:
|
||||||
|
candidates = append(candidates, cluster)
|
||||||
|
case authz.VisibilityWorkspaceShared:
|
||||||
|
if cluster.WorkspaceID == principal.WorkspaceID {
|
||||||
|
candidates = append(candidates, cluster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(candidates, func(i, j int) bool {
|
||||||
|
leftRank := defaultKubeconfigClusterRank(candidates[i])
|
||||||
|
rightRank := defaultKubeconfigClusterRank(candidates[j])
|
||||||
|
if leftRank != rightRank {
|
||||||
|
return leftRank < rightRank
|
||||||
|
}
|
||||||
|
return candidates[i].Name < candidates[j].Name
|
||||||
|
})
|
||||||
|
var firstIssueErr error
|
||||||
|
for _, cluster := range candidates {
|
||||||
|
if kubeconfig, err := s.IssueKubeconfig(ctx, principal.WorkspaceID, cluster.ID, ttl); err == nil {
|
||||||
|
return kubeconfig, nil
|
||||||
|
} else if firstIssueErr == nil {
|
||||||
|
firstIssueErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if firstIssueErr != nil {
|
||||||
|
return nil, firstIssueErr
|
||||||
|
}
|
||||||
|
return nil, entity.ErrClusterNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultKubeconfigClusterRank(cluster *entity.Cluster) int {
|
||||||
|
switch cluster.Visibility {
|
||||||
|
case authz.VisibilityGlobalShared:
|
||||||
|
return 0
|
||||||
|
case authz.VisibilityWorkspaceShared:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) SuspendWorkspace(ctx context.Context, workspaceID string) error {
|
||||||
|
principal, err := authz.RequirePrincipal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return entity.ErrUnauthorized
|
||||||
|
}
|
||||||
|
if !principal.IsAdmin() {
|
||||||
|
return entity.ErrForbidden
|
||||||
|
}
|
||||||
|
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
workspace.Status = entity.WorkspaceSuspended
|
||||||
|
if err := s.workspaceRepo.Update(ctx, workspace); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
clusters, _ := s.clusterRepo.List(ctx)
|
||||||
|
for _, cluster := range clusters {
|
||||||
|
binding, err := s.bindingRepo.Get(ctx, workspaceID, cluster.ID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||||
|
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||||
|
_ = s.tenantClient.SuspendTenant(ctx, cluster, tenantBinding)
|
||||||
|
}
|
||||||
|
s.audit(ctx, principal, "suspend", "workspace", workspace.ID, workspace.Name, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WorkspaceService) audit(ctx context.Context, principal *authz.Principal, action, resourceType, resourceID, resourceName string, details map[string]interface{}) {
|
||||||
|
if s.auditRepo == nil || principal == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.auditRepo.Create(ctx, &entity.AuditLog{
|
||||||
|
WorkspaceID: principal.WorkspaceID,
|
||||||
|
UserID: principal.UserID,
|
||||||
|
Action: action,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceID,
|
||||||
|
ResourceName: resourceName,
|
||||||
|
Details: details,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
144
backend/internal/pkg/authz/authz.go
Normal file
144
backend/internal/pkg/authz/authz.go
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const principalKey contextKey = "principal"
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleUser = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
VisibilityPrivate = "private"
|
||||||
|
VisibilityWorkspaceShared = "workspace_shared"
|
||||||
|
VisibilityGlobalShared = "global_shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnauthenticated = errors.New("authentication required")
|
||||||
|
ErrForbidden = errors.New("permission denied")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Principal struct {
|
||||||
|
UserID string
|
||||||
|
Username string
|
||||||
|
Role string
|
||||||
|
WorkspaceID string
|
||||||
|
WorkspaceName string
|
||||||
|
Namespace string
|
||||||
|
DefaultClusterID string
|
||||||
|
QuotaCPU string
|
||||||
|
QuotaMemory string
|
||||||
|
QuotaGPU string
|
||||||
|
QuotaGPUMem string
|
||||||
|
Permissions []string
|
||||||
|
PermissionVersion int
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPrincipal(ctx context.Context, principal *Principal) context.Context {
|
||||||
|
return context.WithValue(ctx, principalKey, principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrincipalFromContext(ctx context.Context) (*Principal, bool) {
|
||||||
|
principal, ok := ctx.Value(principalKey).(*Principal)
|
||||||
|
return principal, ok && principal != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequirePrincipal(ctx context.Context) (*Principal, error) {
|
||||||
|
principal, ok := PrincipalFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
return principal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Principal) IsAdmin() bool {
|
||||||
|
return p != nil && p.Role == RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanReadResource(p *Principal, workspaceID, ownerID, visibility string) bool {
|
||||||
|
if p == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.IsAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch visibility {
|
||||||
|
case VisibilityGlobalShared:
|
||||||
|
return true
|
||||||
|
case VisibilityWorkspaceShared:
|
||||||
|
return workspaceID != "" && workspaceID == p.WorkspaceID
|
||||||
|
default:
|
||||||
|
return ownerID != "" && ownerID == p.UserID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CanWriteResource(p *Principal, workspaceID, ownerID, visibility string) bool {
|
||||||
|
if p == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.IsAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if visibility == VisibilityGlobalShared {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return workspaceID != "" && workspaceID == p.WorkspaceID && ownerID != "" && ownerID == p.UserID
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeVisibility(role, requested string) string {
|
||||||
|
switch requested {
|
||||||
|
case VisibilityWorkspaceShared:
|
||||||
|
if role == RoleAdmin {
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
return VisibilityPrivate
|
||||||
|
case VisibilityGlobalShared:
|
||||||
|
if role == RoleAdmin {
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
return VisibilityPrivate
|
||||||
|
case VisibilityPrivate:
|
||||||
|
return requested
|
||||||
|
default:
|
||||||
|
return VisibilityPrivate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func PermissionsForRole(role string) []string {
|
||||||
|
if role == RoleAdmin {
|
||||||
|
return []string{
|
||||||
|
"*",
|
||||||
|
"home:view",
|
||||||
|
"workspaces:manage",
|
||||||
|
"users:manage",
|
||||||
|
"configuration:clusters:manage",
|
||||||
|
"configuration:registries:manage",
|
||||||
|
"artifact:registries:view",
|
||||||
|
"artifact:instances:manage",
|
||||||
|
"monitoring:clusters:view",
|
||||||
|
"clusters:manage:any",
|
||||||
|
"registries:manage:any",
|
||||||
|
"instances:manage:any",
|
||||||
|
"kubeconfig:issue:any",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
"home:view",
|
||||||
|
"configuration:clusters:manage_own",
|
||||||
|
"configuration:registries:manage_own",
|
||||||
|
"artifact:registries:view",
|
||||||
|
"artifact:instances:manage_own",
|
||||||
|
"monitoring:clusters:view",
|
||||||
|
"clusters:manage:own",
|
||||||
|
"registries:manage:own",
|
||||||
|
"instances:manage:own",
|
||||||
|
"kubeconfig:issue:own",
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ func TestAESEncryptor(t *testing.T) {
|
|||||||
plaintext string
|
plaintext string
|
||||||
}{
|
}{
|
||||||
{"simple password", "password123"},
|
{"simple password", "password123"},
|
||||||
{"harbor password", "BWGDIP@ssw0rd1401#"},
|
{"registry password", "registry-password-example"},
|
||||||
{"empty string", ""},
|
{"empty string", ""},
|
||||||
{"long certificate", "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pP"},
|
{"long certificate", "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pP"},
|
||||||
{"unicode", "密码123!@#"},
|
{"unicode", "密码123!@#"},
|
||||||
@ -121,4 +121,3 @@ func TestEncryptionConsistency(t *testing.T) {
|
|||||||
t.Error("Decryption should produce original plaintext")
|
t.Error("Decryption should produce original plaintext")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,13 @@ package jwt
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
AccessTokenDuration = 24 * time.Hour // Access Token 有效期
|
AccessTokenDuration = 24 * time.Hour // Access Token 有效期
|
||||||
RefreshTokenDuration = 7 * 24 * time.Hour // Refresh Token 有效期
|
RefreshTokenDuration = 7 * 24 * time.Hour // Refresh Token 有效期
|
||||||
)
|
)
|
||||||
|
|
||||||
// JWTManager JWT 管理器
|
// JWTManager JWT 管理器
|
||||||
@ -26,98 +26,133 @@ func NewJWTManager(secretKey string) *JWTManager {
|
|||||||
|
|
||||||
// Claims JWT Claims
|
// Claims JWT Claims
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
WorkspaceID string `json:"workspace_id"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 生成 Access Token 和 Refresh Token
|
// Generate 生成 Access Token 和 Refresh Token
|
||||||
func (m *JWTManager) Generate(userID, username string) (accessToken, refreshToken string, err error) {
|
func (m *JWTManager) Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error) {
|
||||||
// 生成 Access Token
|
// 生成 Access Token
|
||||||
accessClaims := &Claims{
|
accessClaims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Username: username,
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
|
TokenType: "access",
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
accessToken, err = accessTokenObj.SignedString([]byte(m.secretKey))
|
accessToken, err = accessTokenObj.SignedString([]byte(m.secretKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to sign access token: %w", err)
|
return "", "", fmt.Errorf("failed to sign access token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 Refresh Token
|
// 生成 Refresh Token
|
||||||
refreshClaims := &Claims{
|
refreshClaims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Username: username,
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
WorkspaceID: workspaceID,
|
||||||
|
TokenType: "refresh",
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||||
refreshToken, err = refreshTokenObj.SignedString([]byte(m.secretKey))
|
refreshToken, err = refreshTokenObj.SignedString([]byte(m.secretKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to sign refresh token: %w", err)
|
return "", "", fmt.Errorf("failed to sign refresh token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return accessToken, refreshToken, nil
|
return accessToken, refreshToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify 验证 Token
|
// Verify 验证 Token
|
||||||
func (m *JWTManager) Verify(tokenString string) (userID, username string, err error) {
|
func (m *JWTManager) Verify(tokenString string) (userID, username string, err error) {
|
||||||
userID, username, _, err = m.VerifyWithIssuedAt(tokenString)
|
claims, err := m.VerifyClaims(tokenString, "")
|
||||||
return userID, username, err
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return claims.UserID, claims.Username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) VerifyAccess(tokenString string) (*Claims, error) {
|
||||||
|
return m.VerifyClaims(tokenString, "access")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) VerifyRefresh(tokenString string) (*Claims, error) {
|
||||||
|
return m.VerifyClaims(tokenString, "refresh")
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyWithIssuedAt 验证 Token 并返回签发时间
|
|
||||||
func (m *JWTManager) VerifyWithIssuedAt(tokenString string) (userID, username string, issuedAt int64, err error) {
|
func (m *JWTManager) VerifyWithIssuedAt(tokenString string) (userID, username string, issuedAt int64, err error) {
|
||||||
|
claims, err := m.VerifyClaims(tokenString, "access")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, err
|
||||||
|
}
|
||||||
|
return claims.UserID, claims.Username, claims.IssuedAt.Unix(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *JWTManager) VerifyClaims(tokenString, expectedType string) (*Claims, error) {
|
||||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
}
|
}
|
||||||
return []byte(m.secretKey), nil
|
return []byte(m.secretKey), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", 0, fmt.Errorf("failed to parse token: %w", err)
|
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
claims, ok := token.Claims.(*Claims)
|
||||||
return claims.UserID, claims.Username, claims.IssuedAt.Unix(), nil
|
if !ok || !token.Valid {
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
}
|
}
|
||||||
|
if expectedType != "" && claims.TokenType != expectedType {
|
||||||
return "", "", 0, fmt.Errorf("invalid token")
|
return nil, fmt.Errorf("invalid token type")
|
||||||
|
}
|
||||||
|
if claims.IssuedAt == nil {
|
||||||
|
return nil, fmt.Errorf("token missing issued_at")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh 刷新 Token
|
// Refresh 刷新 Token
|
||||||
func (m *JWTManager) Refresh(refreshToken string) (string, error) {
|
func (m *JWTManager) Refresh(refreshToken string) (string, error) {
|
||||||
// 验证 Refresh Token
|
// 验证 Refresh Token
|
||||||
userID, username, err := m.Verify(refreshToken)
|
claims, err := m.VerifyRefresh(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid refresh token: %w", err)
|
return "", fmt.Errorf("invalid refresh token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成新的 Access Token
|
// 生成新的 Access Token
|
||||||
accessClaims := &Claims{
|
accessClaims := &Claims{
|
||||||
UserID: userID,
|
UserID: claims.UserID,
|
||||||
Username: username,
|
Username: claims.Username,
|
||||||
|
Role: claims.Role,
|
||||||
|
WorkspaceID: claims.WorkspaceID,
|
||||||
|
TokenType: "access",
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||||
newAccessToken, err := accessTokenObj.SignedString([]byte(m.secretKey))
|
newAccessToken, err := accessTokenObj.SignedString([]byte(m.secretKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to sign new access token: %w", err)
|
return "", fmt.Errorf("failed to sign new access token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return newAccessToken, nil
|
return newAccessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -197,8 +197,8 @@ start_pgadmin() {
|
|||||||
echo ""
|
echo ""
|
||||||
print_info "访问地址: http://localhost:5050"
|
print_info "访问地址: http://localhost:5050"
|
||||||
print_info "登录信息:"
|
print_info "登录信息:"
|
||||||
echo " 📧 邮箱: admin@ocdp.local"
|
echo " 📧 邮箱: ${PGADMIN_EMAIL:-admin@ocdp.local}"
|
||||||
echo " 🔑 密码: admin"
|
echo " 🔑 密码: ${PGADMIN_PASSWORD:-change-me}"
|
||||||
echo ""
|
echo ""
|
||||||
print_info "连接数据库配置:"
|
print_info "连接数据库配置:"
|
||||||
echo " 📍 Host: postgres"
|
echo " 📍 Host: postgres"
|
||||||
@ -270,4 +270,3 @@ main() {
|
|||||||
|
|
||||||
# 运行主函数
|
# 运行主函数
|
||||||
main
|
main
|
||||||
|
|
||||||
|
|||||||
@ -23,13 +23,7 @@ TMP_FILE=$(mktemp)
|
|||||||
cat > "$TMP_FILE" <<'EOF'
|
cat > "$TMP_FILE" <<'EOF'
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"users": [
|
"users": [],
|
||||||
{
|
|
||||||
"username": "admin",
|
|
||||||
"password": "admin123",
|
|
||||||
"email": "admin@example.com"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"registries": [],
|
"registries": [],
|
||||||
"clusters": []
|
"clusters": []
|
||||||
}
|
}
|
||||||
@ -38,6 +32,38 @@ EOF
|
|||||||
echo "📋 请按提示输入信息..."
|
echo "📋 请按提示输入信息..."
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# ===== Admin 用户配置 =====
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "👤 Admin 用户配置"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
|
||||||
|
read -p "是否添加初始管理员用户? (y/n) [y]: " ADD_ADMIN
|
||||||
|
ADD_ADMIN=${ADD_ADMIN:-y}
|
||||||
|
|
||||||
|
if [[ "$ADD_ADMIN" == "y" ]]; then
|
||||||
|
read -p "Admin 用户名: " ADMIN_USER
|
||||||
|
read -sp "Admin 密码: " ADMIN_PASS
|
||||||
|
echo ""
|
||||||
|
read -p "Admin 邮箱 [${ADMIN_USER}@example.local]: " ADMIN_EMAIL
|
||||||
|
ADMIN_EMAIL=${ADMIN_EMAIL:-"${ADMIN_USER}@example.local"}
|
||||||
|
|
||||||
|
if [[ -z "$ADMIN_USER" || -z "$ADMIN_PASS" ]]; then
|
||||||
|
echo "❌ Admin 用户名和密码不能为空"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP_USER=$(jq -n \
|
||||||
|
--arg username "$ADMIN_USER" \
|
||||||
|
--arg password "$ADMIN_PASS" \
|
||||||
|
--arg email "$ADMIN_EMAIL" \
|
||||||
|
'{username: $username, password: $password, email: $email}')
|
||||||
|
|
||||||
|
jq ".users += [$TMP_USER]" "$TMP_FILE" > "${TMP_FILE}.tmp" && mv "${TMP_FILE}.tmp" "$TMP_FILE"
|
||||||
|
echo "✅ Admin 用户 '$ADMIN_USER' 已添加"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
# ===== Registries 配置 =====
|
# ===== Registries 配置 =====
|
||||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
echo "📦 Registry 配置"
|
echo "📦 Registry 配置"
|
||||||
@ -47,20 +73,23 @@ read -p "是否添加 Registry? (y/n) [y]: " ADD_REGISTRY
|
|||||||
ADD_REGISTRY=${ADD_REGISTRY:-y}
|
ADD_REGISTRY=${ADD_REGISTRY:-y}
|
||||||
|
|
||||||
if [[ "$ADD_REGISTRY" == "y" ]]; then
|
if [[ "$ADD_REGISTRY" == "y" ]]; then
|
||||||
read -p "Registry 名称 [harbor-bwgdi]: " REGISTRY_NAME
|
read -p "Registry 名称 [harbor]: " REGISTRY_NAME
|
||||||
REGISTRY_NAME=${REGISTRY_NAME:-harbor-bwgdi}
|
REGISTRY_NAME=${REGISTRY_NAME:-harbor}
|
||||||
|
|
||||||
read -p "Registry URL [https://harbor.bwgdi.com]: " REGISTRY_URL
|
read -p "Registry URL: " REGISTRY_URL
|
||||||
REGISTRY_URL=${REGISTRY_URL:-https://harbor.bwgdi.com}
|
|
||||||
|
|
||||||
read -p "Registry 描述 [BWGDI Harbor Registry]: " REGISTRY_DESC
|
read -p "Registry 描述 [Harbor Registry]: " REGISTRY_DESC
|
||||||
REGISTRY_DESC=${REGISTRY_DESC:-"BWGDI Harbor Registry"}
|
REGISTRY_DESC=${REGISTRY_DESC:-"Harbor Registry"}
|
||||||
|
|
||||||
read -p "Registry 用户名 [admin]: " REGISTRY_USER
|
read -p "Registry 用户名(推荐 Harbor robot 账号): " REGISTRY_USER
|
||||||
REGISTRY_USER=${REGISTRY_USER:-admin}
|
|
||||||
|
|
||||||
read -sp "Registry 密码: " REGISTRY_PASS
|
read -sp "Registry 密码: " REGISTRY_PASS
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
if [[ -z "$REGISTRY_URL" ]]; then
|
||||||
|
echo "❌ Registry URL 不能为空"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
read -p "是否跳过 SSL 验证? (y/n) [n]: " REGISTRY_INSECURE
|
read -p "是否跳过 SSL 验证? (y/n) [n]: " REGISTRY_INSECURE
|
||||||
REGISTRY_INSECURE=${REGISTRY_INSECURE:-n}
|
REGISTRY_INSECURE=${REGISTRY_INSECURE:-n}
|
||||||
@ -72,17 +101,14 @@ if [[ "$ADD_REGISTRY" == "y" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 添加 Registry 到配置
|
# 添加 Registry 到配置
|
||||||
TMP_REGISTRY=$(cat <<JSON
|
TMP_REGISTRY=$(jq -n \
|
||||||
{
|
--arg name "$REGISTRY_NAME" \
|
||||||
"name": "$REGISTRY_NAME",
|
--arg url "$REGISTRY_URL" \
|
||||||
"url": "$REGISTRY_URL",
|
--arg description "$REGISTRY_DESC" \
|
||||||
"description": "$REGISTRY_DESC",
|
--arg username "$REGISTRY_USER" \
|
||||||
"username": "$REGISTRY_USER",
|
--arg password "$REGISTRY_PASS" \
|
||||||
"password": "$REGISTRY_PASS",
|
--argjson insecure "$INSECURE_VALUE" \
|
||||||
"insecure": $INSECURE_VALUE
|
'{name: $name, url: $url, description: $description, username: $username, password: $password, insecure: $insecure}')
|
||||||
}
|
|
||||||
JSON
|
|
||||||
)
|
|
||||||
|
|
||||||
jq ".registries += [$TMP_REGISTRY]" "$TMP_FILE" > "${TMP_FILE}.tmp" && mv "${TMP_FILE}.tmp" "$TMP_FILE"
|
jq ".registries += [$TMP_REGISTRY]" "$TMP_FILE" > "${TMP_FILE}.tmp" && mv "${TMP_FILE}.tmp" "$TMP_FILE"
|
||||||
echo "✅ Registry '$REGISTRY_NAME' 已添加"
|
echo "✅ Registry '$REGISTRY_NAME' 已添加"
|
||||||
@ -232,4 +258,3 @@ echo " curl http://localhost:8080/api/v1/clusters"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "✨ 完成!"
|
echo "✨ 完成!"
|
||||||
|
|
||||||
|
|||||||
@ -75,11 +75,10 @@ echo " - Health: http://localhost:8080/health"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "📍 数据库管理:"
|
echo "📍 数据库管理:"
|
||||||
echo " - pgAdmin: http://localhost:5050"
|
echo " - pgAdmin: http://localhost:5050"
|
||||||
echo " Email: admin@ocdp.local"
|
echo " Email: ${PGADMIN_EMAIL:-admin@ocdp.local}"
|
||||||
echo " Password: admin"
|
echo " Password: ${PGADMIN_PASSWORD:-change-me}"
|
||||||
echo ""
|
echo ""
|
||||||
echo "✨ 按 Ctrl+C 停止服务"
|
echo "✨ 按 Ctrl+C 停止服务"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
./bin/ocdp-backend
|
./bin/ocdp-backend
|
||||||
|
|
||||||
|
|||||||
@ -87,9 +87,11 @@ test_api() {
|
|||||||
log_info "测试 API..."
|
log_info "测试 API..."
|
||||||
|
|
||||||
# 测试注册
|
# 测试注册
|
||||||
|
local test_username="testuser$RANDOM"
|
||||||
|
local test_password="test123"
|
||||||
register_response=$(curl -s -X POST http://localhost:8080/api/v1/auth/register \
|
register_response=$(curl -s -X POST http://localhost:8080/api/v1/auth/register \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"username":"testuser'"$RANDOM"'","password":"test123","email":"test@example.com"}')
|
-d '{"username":"'"$test_username"'","password":"'"$test_password"'","email":"test@example.com"}')
|
||||||
|
|
||||||
if echo "$register_response" | grep -q "id"; then
|
if echo "$register_response" | grep -q "id"; then
|
||||||
log_success "$mode 模式 API 注册测试通过"
|
log_success "$mode 模式 API 注册测试通过"
|
||||||
@ -100,7 +102,7 @@ test_api() {
|
|||||||
# 测试登录
|
# 测试登录
|
||||||
login_response=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
login_response=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"username":"admin","password":"admin123"}')
|
-d '{"username":"'"$test_username"'","password":"'"$test_password"'"}')
|
||||||
|
|
||||||
if echo "$login_response" | grep -q "accessToken"; then
|
if echo "$login_response" | grep -q "accessToken"; then
|
||||||
log_success "$mode 模式 API 登录测试通过"
|
log_success "$mode 模式 API 登录测试通过"
|
||||||
@ -392,4 +394,3 @@ main() {
|
|||||||
|
|
||||||
# 执行主函数
|
# 执行主函数
|
||||||
main
|
main
|
||||||
|
|
||||||
|
|||||||
598
database.md
Normal file
598
database.md
Normal file
@ -0,0 +1,598 @@
|
|||||||
|
# OCDP 数据库结构说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
OCDP (Open Container Deployment Platform) 是一个多租户容器部署平台,支持:
|
||||||
|
- 多 Workspace 隔离
|
||||||
|
- RBAC 权限控制 (Admin / User)
|
||||||
|
- Kubernetes 集群管理
|
||||||
|
- OCI Registry 集成 (Harbor)
|
||||||
|
- Helm Chart 部署
|
||||||
|
- Values 模板版本管理
|
||||||
|
- 资源配额控制
|
||||||
|
- 审计日志
|
||||||
|
|
||||||
|
## 数据库配置
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# PostgreSQL 连接信息
|
||||||
|
Host: localhost
|
||||||
|
Port: 5430 (docker) / 5432 (local)
|
||||||
|
Database: ocdp
|
||||||
|
User: ocdp
|
||||||
|
Password: ocdp_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 表结构
|
||||||
|
|
||||||
|
### 1. users - 用户表
|
||||||
|
|
||||||
|
存储用户账户信息,支持多租户和角色管理。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
username VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'user', -- 'admin' | 'user'
|
||||||
|
workspace_id VARCHAR(36), -- 所属工作空间,admin 为 NULL 表示全局
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 账户是否激活
|
||||||
|
must_change_password BOOLEAN NOT NULL DEFAULT FALSE, -- 首次登录必须修改密码
|
||||||
|
revoked_after TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00', -- 全局 Token 撤销时间
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | VARCHAR(36) | 主键 UUID | 550e8400-e29b-41d4-a716-446655440000 |
|
||||||
|
| username | VARCHAR(255) | 用户名,唯一 | admin |
|
||||||
|
| password_hash | TEXT | bcrypt 密码哈希 | $2a$10$... |
|
||||||
|
| email | VARCHAR(255) | 邮箱 | admin@ocdp.local |
|
||||||
|
| role | VARCHAR(20) | 角色:admin/user | admin |
|
||||||
|
| workspace_id | VARCHAR(36) | 所属工作空间 ID | workspace-uuid |
|
||||||
|
| is_active | BOOLEAN | 账户是否激活 | true |
|
||||||
|
| must_change_password | BOOLEAN | 首次登录必须修改密码 | false |
|
||||||
|
| revoked_after | TIMESTAMP | Token 撤销时间(修改密码后自动撤销旧 Token) | 2024-01-01 10:00:00 |
|
||||||
|
| created_at | TIMESTAMP | 创建时间 | 2024-01-01 10:00:00 |
|
||||||
|
| updated_at | TIMESTAMP | 更新时间 | 2024-01-01 10:00:00 |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- `idx_users_username` - 用户名查询
|
||||||
|
- `idx_users_role` - 角色筛选
|
||||||
|
- `idx_users_workspace_id` - 工作空间筛选
|
||||||
|
- `idx_users_is_active` - 激活状态筛选
|
||||||
|
|
||||||
|
**角色说明**:
|
||||||
|
- `admin`: 管理员,可管理所有 Workspace 和资源,workspace_id 为 NULL
|
||||||
|
- `user`: 普通用户,仅可访问自己 Workspace 内的资源
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. workspaces - 工作空间表
|
||||||
|
|
||||||
|
租户/团队隔离单元。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE workspaces (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
created_by VARCHAR(36),
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | VARCHAR(36) | 主键 UUID | workspace-uuid |
|
||||||
|
| name | VARCHAR(255) | 工作空间名称,唯一 | team-alpha |
|
||||||
|
| description | TEXT | 描述 | Alpha 团队工作空间 |
|
||||||
|
| created_by | VARCHAR(36) | 创建者用户 ID | user-uuid |
|
||||||
|
| created_at | TIMESTAMP | 创建时间 | 2024-01-01 10:00:00 |
|
||||||
|
| updated_at | TIMESTAMP | 更新时间 | 2024-01-01 10:00:00 |
|
||||||
|
|
||||||
|
**索引**:
|
||||||
|
- `idx_workspaces_name` - 名称查询
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. workspace_quotas - 工作空间配额表
|
||||||
|
|
||||||
|
每个 Workspace 的资源配额限制。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE workspace_quotas (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36) NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
resource_type VARCHAR(50) NOT NULL, -- 'cpu' | 'gpu' | 'gpu_memory'
|
||||||
|
hard_limit DECIMAL(10,2) NOT NULL, -- 硬限制(0 表示无限制)
|
||||||
|
soft_limit DECIMAL(10,2) NOT NULL, -- 软限制(警告阈值)
|
||||||
|
used DECIMAL(10,2) NOT NULL DEFAULT 0, -- 当前使用量
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(workspace_id, resource_type)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | VARCHAR(36) | 主键 UUID | quota-uuid |
|
||||||
|
| workspace_id | VARCHAR(36) | 所属工作空间 ID | workspace-uuid |
|
||||||
|
| resource_type | VARCHAR(50) | 资源类型:cpu/gpu/gpu_memory | cpu |
|
||||||
|
| hard_limit | DECIMAL(10,2) | 硬限制(0=无限制) | 10.00 |
|
||||||
|
| soft_limit | DECIMAL(10,2) | 软限制(警告阈值) | 8.00 |
|
||||||
|
| used | DECIMAL(10,2) | 当前使用量 | 5.00 |
|
||||||
|
| created_at | TIMESTAMP | 创建时间 | 2024-01-01 10:00:00 |
|
||||||
|
| updated_at | TIMESTAMP | 更新时间 | 2024-01-01 10:00:00 |
|
||||||
|
|
||||||
|
**配额检查逻辑**:
|
||||||
|
1. 部署实例前检查 `used + new_request <= hard_limit`
|
||||||
|
2. 超过硬限制返回 403 Forbidden
|
||||||
|
3. 超过软限制发送警告通知
|
||||||
|
4. 实例删除后释放配额
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. clusters - Kubernetes 集群表
|
||||||
|
|
||||||
|
管理 Kubernetes 集群连接信息。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE clusters (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36), -- 所属工作空间,NULL 表示全局共享
|
||||||
|
owner_id VARCHAR(36), -- 创建者用户 ID
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
host TEXT NOT NULL, -- Kubernetes API Server URL
|
||||||
|
ca_data TEXT, -- CA 证书(Base64 编码)
|
||||||
|
cert_data TEXT, -- 客户端证书(Base64 编码)
|
||||||
|
key_data TEXT, -- 客户端密钥(Base64 编码)
|
||||||
|
token TEXT, -- Bearer Token(与证书认证二选一)
|
||||||
|
description TEXT,
|
||||||
|
isolation_mode VARCHAR(20) NOT NULL DEFAULT 'namespace', -- 'namespace' | 'cluster'
|
||||||
|
default_namespace VARCHAR(255), -- 默认 namespace 前缀
|
||||||
|
is_shared BOOLEAN NOT NULL DEFAULT FALSE, -- 是否为共享集群
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | VARCHAR(36) | 主键 UUID | cluster-uuid |
|
||||||
|
| workspace_id | VARCHAR(36) | 所属工作空间 ID | workspace-uuid |
|
||||||
|
| owner_id | VARCHAR(36) | 创建者用户 ID | user-uuid |
|
||||||
|
| name | VARCHAR(255) | 集群名称,唯一 | prod-k8s |
|
||||||
|
| host | VARCHAR(255) | Kubernetes API URL | https://k8s.example.com:6443 |
|
||||||
|
| ca_data | TEXT | CA 证书 Base64 | LS0tLS1... |
|
||||||
|
| cert_data | TEXT | 客户端证书 Base64 | LS0tLS1... |
|
||||||
|
| key_data | TEXT | 客户端密钥 Base64 | LS0tLS1... |
|
||||||
|
| token | TEXT | Bearer Token | eyJhbGci... |
|
||||||
|
| description | TEXT | 描述 | 生产环境集群 |
|
||||||
|
| isolation_mode | VARCHAR(20) | 隔离模式:namespace/cluster | namespace |
|
||||||
|
| default_namespace | VARCHAR(255) | 默认 namespace 前缀 | team-alpha |
|
||||||
|
| is_shared | BOOLEAN | 是否共享(admin 创建供多 Workspace 使用) | false |
|
||||||
|
| created_at | TIMESTAMP | 创建时间 | 2024-01-01 10:00:00 |
|
||||||
|
| updated_at | TIMESTAMP | 更新时间 | 2024-01-01 10:00:00 |
|
||||||
|
|
||||||
|
**隔离模式说明**:
|
||||||
|
- `namespace`: 共享集群模式,多个 Workspace 使用不同 namespace
|
||||||
|
- 部署时自动分配:`{default_namespace}-{instance_name}`
|
||||||
|
- `cluster`: 私有集群模式,每个 Workspace 独立集群或独立凭证
|
||||||
|
|
||||||
|
**认证方式**:
|
||||||
|
1. 证书认证:`ca_data` + `cert_data` + `key_data`
|
||||||
|
2. Token 认证:`token`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. registries - OCI Registry 表
|
||||||
|
|
||||||
|
管理 Docker/OCI 镜像仓库(支持 Harbor)。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE registries (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36), -- 所属工作空间,NULL 表示全局共享
|
||||||
|
owner_id VARCHAR(36), -- 创建者用户 ID
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL, -- Registry URL
|
||||||
|
description TEXT,
|
||||||
|
username VARCHAR(255), -- 认证用户名
|
||||||
|
password TEXT, -- 认证密码(加密存储)
|
||||||
|
insecure BOOLEAN DEFAULT FALSE, -- 是否跳过 TLS 验证
|
||||||
|
is_shared BOOLEAN DEFAULT FALSE, -- 是否为共享 Registry
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | VARCHAR(36) | 主键 UUID | registry-uuid |
|
||||||
|
| workspace_id | VARCHAR(36) | 所属工作空间 ID | workspace-uuid |
|
||||||
|
| owner_id | VARCHAR(36) | 创建者用户 ID | user-uuid |
|
||||||
|
| name | VARCHAR(255) | Registry 名称,唯一 | harbor-prod |
|
||||||
|
| url | TEXT | Registry URL | https://harbor.example.com |
|
||||||
|
| description | TEXT | 描述 | 生产环境 Harbor |
|
||||||
|
| username | VARCHAR(255) | 认证用户名 | admin |
|
||||||
|
| password | TEXT | 认证密码(加密) | encrypted... |
|
||||||
|
| insecure | BOOLEAN | 跳过 TLS 验证 | false |
|
||||||
|
| is_shared | BOOLEAN | 是否共享 | false |
|
||||||
|
| created_at | TIMESTAMP | 创建时间 | 2024-01-01 10:00:00 |
|
||||||
|
| updated_at | TIMESTAMP | 更新时间 | 2024-01-01 10:00:00 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. instances - Helm 实例表
|
||||||
|
|
||||||
|
部署的 Helm Release 管理。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE instances (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36), -- 所属工作空间
|
||||||
|
owner_id VARCHAR(36), -- 创建者用户 ID
|
||||||
|
cluster_id VARCHAR(36) NOT NULL,
|
||||||
|
registry_id VARCHAR(36) NOT NULL,
|
||||||
|
chart_reference_id VARCHAR(36), -- 引用的 Chart 引用
|
||||||
|
values_template_id VARCHAR(36), -- 使用的 Values 模板
|
||||||
|
|
||||||
|
name VARCHAR(255) NOT NULL, -- Helm Release 名称
|
||||||
|
namespace VARCHAR(255) NOT NULL, -- Kubernetes 命名空间
|
||||||
|
repository TEXT NOT NULL, -- OCI Repository (e.g., charts/app)
|
||||||
|
chart VARCHAR(255) NOT NULL, -- Chart 名称
|
||||||
|
version VARCHAR(255) NOT NULL, -- Chart 版本
|
||||||
|
description TEXT,
|
||||||
|
values JSONB, -- Helm Values (JSON)
|
||||||
|
values_yaml TEXT, -- Helm Values (YAML)
|
||||||
|
user_override_yaml TEXT, -- 用户额外覆盖配置
|
||||||
|
|
||||||
|
status VARCHAR(50) NOT NULL, -- 实例状态
|
||||||
|
status_reason TEXT, -- 状态说明
|
||||||
|
last_operation VARCHAR(50), -- 最后操作类型
|
||||||
|
last_error TEXT, -- 最近错误
|
||||||
|
revision INTEGER NOT NULL DEFAULT 1, -- Helm Release Revision
|
||||||
|
|
||||||
|
cpu_requested DECIMAL(10,2) NOT NULL DEFAULT 0, -- CPU 请求量 (cores)
|
||||||
|
memory_requested VARCHAR(50) NOT NULL DEFAULT '0Mi', -- 内存请求量
|
||||||
|
gpu_requested DECIMAL(10,2) NOT NULL DEFAULT 0, -- GPU 请求量 (cards)
|
||||||
|
gpu_memory_requested VARCHAR(50) NOT NULL DEFAULT '0Mi', -- GPU 内存请求量
|
||||||
|
|
||||||
|
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,
|
||||||
|
UNIQUE (cluster_id, name, namespace)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| id | VARCHAR(36) | 主键 UUID | instance-uuid |
|
||||||
|
| workspace_id | VARCHAR(36) | 所属工作空间 ID | workspace-uuid |
|
||||||
|
| owner_id | VARCHAR(36) | 创建者用户 ID | user-uuid |
|
||||||
|
| cluster_id | VARCHAR(36) | 所属集群 ID | cluster-uuid |
|
||||||
|
| registry_id | VARCHAR(36) | 所属 Registry ID | registry-uuid |
|
||||||
|
| chart_reference_id | VARCHAR(36) | Chart 引用 ID | chart-ref-uuid |
|
||||||
|
| values_template_id | VARCHAR(36) | Values 模板 ID | template-uuid |
|
||||||
|
| name | VARCHAR(255) | Release 名称(RFC 1123) | my-app |
|
||||||
|
| namespace | VARCHAR(255) | Kubernetes 命名空间 | team-alpha-my-app |
|
||||||
|
| repository | TEXT | OCI Repository | harbor.example.com/charts/nginx |
|
||||||
|
| chart | VARCHAR(255) | Chart 名称 | nginx |
|
||||||
|
| version | VARCHAR(255) | Chart 版本 | 1.0.0 |
|
||||||
|
| description | TEXT | 描述 | Nginx 应用 |
|
||||||
|
| values | JSONB | Values JSON | {"replicas": 2} |
|
||||||
|
| values_yaml | TEXT | Values YAML | replicas: 2 |
|
||||||
|
| user_override_yaml | TEXT | 用户覆盖配置 | replicas: 3 |
|
||||||
|
| status | VARCHAR(50) | 状态 | deployed |
|
||||||
|
| status_reason | TEXT | 状态说明 | Install complete |
|
||||||
|
| last_operation | VARCHAR(50) | 最后操作 | install |
|
||||||
|
| last_error | TEXT | 错误信息 | - |
|
||||||
|
| revision | INTEGER | Helm Revision | 1 |
|
||||||
|
| cpu_requested | DECIMAL(10,2) | CPU 请求 | 2.00 |
|
||||||
|
| memory_requested | VARCHAR(50) | 内存请求 | 1Gi |
|
||||||
|
| gpu_requested | DECIMAL(10,2) | GPU 请求 | 0 |
|
||||||
|
| gpu_memory_requested | VARCHAR(50) | GPU 内存 | 0Mi |
|
||||||
|
| created_at | TIMESTAMP | 创建时间 | 2024-01-01 10:00:00 |
|
||||||
|
| updated_at | TIMESTAMP | 更新时间 | 2024-01-01 10:00:00 |
|
||||||
|
|
||||||
|
**状态说明**:
|
||||||
|
| 状态 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| deployed | 部署成功 |
|
||||||
|
| failed | 部署失败 |
|
||||||
|
| pending-install | 安装中 |
|
||||||
|
| pending-upgrade | 升级中 |
|
||||||
|
| pending-rollback | 回滚中 |
|
||||||
|
| pending-delete | 删除中 |
|
||||||
|
| uninstalled | 已卸载 |
|
||||||
|
| superseded | 已被取代 |
|
||||||
|
| unknown | 未知 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. storage_backends - 存储后端表
|
||||||
|
|
||||||
|
NFS/PV/HostPath 存储配置。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE storage_backends (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36),
|
||||||
|
owner_id VARCHAR(36),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL, -- 'nfs' | 'pv' | 'hostPath'
|
||||||
|
config JSONB NOT NULL, -- 存储配置
|
||||||
|
description TEXT,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE, -- 是否默认存储
|
||||||
|
is_shared BOOLEAN NOT NULL DEFAULT FALSE, -- 是否共享
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(workspace_id, name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config 结构**:
|
||||||
|
```json
|
||||||
|
// NFS
|
||||||
|
{"nfs": {"server": "192.168.1.100", "path": "/data"}}
|
||||||
|
|
||||||
|
// PV
|
||||||
|
{"pv": {"storageClassName": "nfs", "capacity": "10Gi", "accessModes": ["ReadWriteMany"]}}
|
||||||
|
|
||||||
|
// HostPath
|
||||||
|
{"hostPath": {"path": "/mnt/data"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. chart_references - Chart 引用表
|
||||||
|
|
||||||
|
管理可用的 Helm Chart 引用。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE chart_references (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36),
|
||||||
|
registry_id VARCHAR(36),
|
||||||
|
repository VARCHAR(500) NOT NULL, -- OCI repository path
|
||||||
|
chart_name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(workspace_id, registry_id, repository)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. values_templates - Values 模板表
|
||||||
|
|
||||||
|
Helm Values 模板,支持版本管理。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE values_templates (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36),
|
||||||
|
owner_id VARCHAR(36),
|
||||||
|
chart_reference_id VARCHAR(36),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
values_yaml TEXT NOT NULL,
|
||||||
|
version INTEGER NOT NULL DEFAULT 1, -- 模板版本号
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(workspace_id, chart_reference_id, name)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**版本管理**:
|
||||||
|
- 每次更新创建新版本(version + 1)
|
||||||
|
- 支持回滚到历史版本
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. user_config_overrides - 用户配置覆盖表
|
||||||
|
|
||||||
|
用户个人配置覆盖。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_config_overrides (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36),
|
||||||
|
user_id VARCHAR(36),
|
||||||
|
target_type VARCHAR(50) NOT NULL, -- 'storage' | 'template' | 'global'
|
||||||
|
target_id VARCHAR(36),
|
||||||
|
config JSONB NOT NULL, -- 覆盖配置
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0, -- 优先级
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. audit_logs - 审计日志表
|
||||||
|
|
||||||
|
记录所有操作行为。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workspace_id VARCHAR(36),
|
||||||
|
user_id VARCHAR(36),
|
||||||
|
action VARCHAR(100) NOT NULL, -- 'create' | 'update' | 'delete' | 'deploy' | 'scale'
|
||||||
|
resource_type VARCHAR(50) NOT NULL, -- 'cluster' | 'registry' | 'instance' | ...
|
||||||
|
resource_id VARCHAR(36),
|
||||||
|
resource_name VARCHAR(255),
|
||||||
|
details JSONB,
|
||||||
|
ip_address VARCHAR(50),
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. schema_migrations - 迁移版本表
|
||||||
|
|
||||||
|
数据库版本记录。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE schema_migrations (
|
||||||
|
version VARCHAR(50) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ER 关系图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ workspaces │
|
||||||
|
│ (id, name, description, created_by, created_at, updated_at) │
|
||||||
|
└────────────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ 1:N
|
||||||
|
┌────────────────────────────┼────────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
|
||||||
|
│ workspace_quotas│ │ clusters │ │ registries │
|
||||||
|
│ (workspace_id, │ │ (workspace_id, │ │ (workspace_id, │
|
||||||
|
│ resource_type, │ │ owner_id, name, │ │ owner_id, name, │
|
||||||
|
│ hard_limit, │ │ host, is_shared) │ │ url, is_shared) │
|
||||||
|
│ soft_limit, used)│ └─────────┬─────────┘ └────────┬─────────┘
|
||||||
|
└───────────────────┘ │ │
|
||||||
|
│ │
|
||||||
|
┌───────────────────────────┼───────────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────┐ ┌───────────────────┐
|
||||||
|
│ instances │ │ storage_backends│
|
||||||
|
│ (workspace_id, │ │ (workspace_id, │
|
||||||
|
│ owner_id, │ │ owner_id, name, │
|
||||||
|
│ cluster_id, │ │ type, config) │
|
||||||
|
│ registry_id, │ └───────────────────┘
|
||||||
|
│ values_template) │
|
||||||
|
└───────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ users │
|
||||||
|
│ (id, username, password_hash, email, role, workspace_id, is_active) │
|
||||||
|
└────────────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────────┼────────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
|
||||||
|
│ chart_references│ │ values_templates │ │ audit_logs │
|
||||||
|
│ (workspace_id, │ │ (workspace_id, │ │ (user_id, action,│
|
||||||
|
│ registry_id, │ │ owner_id, │ │ resource_type) │
|
||||||
|
│ repository) │ │ chart_ref_id) │ └───────────────────┘
|
||||||
|
└───────────────────┘ └───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 资源可见性规则
|
||||||
|
|
||||||
|
| 用户角色 | 可见范围 |
|
||||||
|
|---------|---------|
|
||||||
|
| Admin | 所有 Workspace 的所有资源(workspace_id 为 NULL 或有值都能看到) |
|
||||||
|
| User | 仅自己 Workspace 的资源 |
|
||||||
|
| 共享资源 | `is_shared=TRUE` 时,同 Workspace 内可见 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用 SQL 操作
|
||||||
|
|
||||||
|
### 查询用户及其 Workspace
|
||||||
|
```sql
|
||||||
|
SELECT u.id, u.username, u.role, w.name as workspace_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN workspaces w ON u.workspace_id = w.id
|
||||||
|
WHERE u.is_active = TRUE;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询 Workspace 配额使用情况
|
||||||
|
```sql
|
||||||
|
SELECT w.name as workspace,
|
||||||
|
q.resource_type,
|
||||||
|
q.hard_limit,
|
||||||
|
q.soft_limit,
|
||||||
|
q.used,
|
||||||
|
CASE WHEN q.hard_limit > 0 THEN ROUND(q.used / q.hard_limit * 100, 2) ELSE 0 END as usage_percent
|
||||||
|
FROM workspace_quotas q
|
||||||
|
JOIN workspaces w ON q.workspace_id = w.id;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询用户可用的集群
|
||||||
|
```sql
|
||||||
|
-- Admin: 所有集群
|
||||||
|
SELECT * FROM clusters;
|
||||||
|
|
||||||
|
-- User: 自己 Workspace 的集群 + 共享集群
|
||||||
|
SELECT * FROM clusters
|
||||||
|
WHERE workspace_id = 'user-workspace-id'
|
||||||
|
OR is_shared = TRUE;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询实例状态统计
|
||||||
|
```sql
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM instances
|
||||||
|
WHERE workspace_id = 'workspace-id'
|
||||||
|
GROUP BY status;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询审计日志
|
||||||
|
```sql
|
||||||
|
SELECT a.created_at, u.username, a.action, a.resource_type, a.resource_name
|
||||||
|
FROM audit_logs a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.workspace_id = 'workspace-id'
|
||||||
|
ORDER BY a.created_at DESC
|
||||||
|
LIMIT 50;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移历史
|
||||||
|
|
||||||
|
| 版本 | 说明 | 日期 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1.0.0 | 初始版本(单租户) | 2024-01 |
|
||||||
|
| v2.0.0-multi-tenant | 多租户迁移:添加 workspaces, quotas, 扩展 users/clusters/registries/instances | 2025-04 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 初始数据
|
||||||
|
|
||||||
|
### 创建 Admin 用户
|
||||||
|
```sql
|
||||||
|
-- 默认密码: admin123 (bcrypt hash 需由应用设置)
|
||||||
|
INSERT INTO users (id, username, password_hash, email, role, workspace_id, is_active, must_change_password)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-0000-0000-000000000001',
|
||||||
|
'admin',
|
||||||
|
'$2a$10$placeholder', -- 由应用初始化时设置
|
||||||
|
'admin@ocdp.local',
|
||||||
|
'admin',
|
||||||
|
NULL, -- admin 的 workspace_id 为 NULL,表示全局
|
||||||
|
TRUE,
|
||||||
|
TRUE -- 首次登录必须修改密码
|
||||||
|
);
|
||||||
|
```
|
||||||
@ -1,19 +1,82 @@
|
|||||||
# ==================================================
|
# ==================================================
|
||||||
# OCDP Docker Compose (frontend + gateway layer)
|
# OCDP Docker Compose (complete local stack)
|
||||||
# ==================================================
|
# ==================================================
|
||||||
# 使用方式:
|
# 使用方式:
|
||||||
# docker compose -f docker-compose.yml \
|
# docker compose up --build
|
||||||
# -f ./backend/docker-compose.yml \
|
|
||||||
# --profile backend up --build -d
|
|
||||||
#
|
#
|
||||||
# 说明:
|
# 说明:
|
||||||
# - 本文件只负责前端构建和 Nginx。
|
# - 本文件是本地部署主入口,包含 PostgreSQL、Backend、前端构建和 Nginx。
|
||||||
# - Backend / PostgreSQL / pgAdmin 由 backend/docker-compose.yml 提供。
|
# - 默认使用高位宿主端口,避免和本机其他项目冲突。
|
||||||
# - Nginx 统一监听 80/443(默认映射 WEB_HTTP_PORT=80、WEB_HTTPS_PORT=443),
|
# - Nginx 统一监听容器内 80/443(默认映射 WEB_HTTP_PORT=18080、WEB_HTTPS_PORT=18443),
|
||||||
# 根据路径转发:/api/* → backend,其他路径 → 前端静态文件。
|
# 根据路径转发:/api/* → backend,其他路径 → 前端静态文件。
|
||||||
# ==================================================
|
# ==================================================
|
||||||
|
|
||||||
services:
|
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:-15432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-ocdp}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 60s
|
||||||
|
networks:
|
||||||
|
- ocdp-network
|
||||||
|
|
||||||
|
# --------------------------------------------------
|
||||||
|
# Backend API
|
||||||
|
# --------------------------------------------------
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||||
|
GOSUMDB: ${GOSUMDB:-sum.golang.google.cn}
|
||||||
|
image: ocdp-backend:latest
|
||||||
|
container_name: ocdp-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- path: ./.env
|
||||||
|
required: false
|
||||||
|
format: raw
|
||||||
|
environment:
|
||||||
|
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
||||||
|
PORT: 8080
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-18081}:8080"
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
- ./data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ocdp-network
|
||||||
|
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
# 构建前端静态资源 (一次性 Job)
|
# 构建前端静态资源 (一次性 Job)
|
||||||
# --------------------------------------------------
|
# --------------------------------------------------
|
||||||
@ -57,15 +120,17 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
frontend-build:
|
frontend-build:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_HTTP_PORT:-80}:80"
|
- "${WEB_HTTP_PORT:-18080}:80"
|
||||||
- "${WEB_HTTPS_PORT:-443}:443"
|
- "${WEB_HTTPS_PORT:-18443}:443"
|
||||||
volumes:
|
volumes:
|
||||||
- frontend_dist:/usr/share/nginx/html:ro
|
- frontend_dist:/usr/share/nginx/html:ro
|
||||||
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ./infra/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- ./infra/nginx/certs:/etc/nginx/certs:ro
|
- ./infra/nginx/certs:/etc/nginx/certs:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://localhost/healthz || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/healthz || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@ -84,6 +149,8 @@ networks:
|
|||||||
# Volumes
|
# Volumes
|
||||||
# ==================================================
|
# ==================================================
|
||||||
volumes:
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
name: ocdp-postgres-data
|
||||||
frontend_dist:
|
frontend_dist:
|
||||||
driver: local
|
driver: local
|
||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
|
|||||||
74
docs/UNRESOLVED-BUGS.md
Normal file
74
docs/UNRESOLVED-BUGS.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# OCDP 未修复问题清单
|
||||||
|
|
||||||
|
**最后更新:** 2026-05-14 (Round 3 回归测试)
|
||||||
|
**测试覆盖:** 3 轮测试 (Round 1: v1 基线, Round 2: 配额+YAML, Round 3: 回归+新功能)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知未修复 Bug (15 个)
|
||||||
|
|
||||||
|
### P1 — 高优先级 (1)
|
||||||
|
|
||||||
|
| # | 标题 | 严重度 | 描述 | Round |
|
||||||
|
|----|------|--------|------|-------|
|
||||||
|
| 1 | Detail API 返回 replicas: 0 | **P1** | `GET /instances/{id}` 始终返回 `replicas: 0`,与 List API 不一致 | R3 NEW |
|
||||||
|
|
||||||
|
### P2 — 中优先级 (8)
|
||||||
|
|
||||||
|
| # | 标题 | 严重度 | 描述 | Round |
|
||||||
|
|----|------|--------|------|-------|
|
||||||
|
| 2 | List API 移除 values 字段 | **P2** | List instances 不再返回 `values`,仅在详情API返回。可能是性能优化,但属于 API 行为变更 | R3 NEW |
|
||||||
|
| 3 | API 层无配额预检查 | **P2** | 后端接受所有部署请求(返回200),不验证是否超配额。K8s ResourceQuota 在 pod 级阻止,但 Helm release 仍创建 | R2 |
|
||||||
|
| 4 | Values 冲突时无警告 | **P2** | 同时提供 `values` JSON 和 `valuesYaml` 时,JSON 静默覆盖 YAML,无任何警告 | R2 |
|
||||||
|
| 5 | Tags 端点缺失 | **P2** | `GET /registries/{id}/repositories/{repo}/tags` 返回 404 | R1 |
|
||||||
|
| 6 | Metrics API 缺失 | **P2** | `GET /monitoring/clusters/{id}/metrics` 返回 404 | R1 |
|
||||||
|
| 7 | Stats API 缺失 | **P2** | `GET /clusters/{id}/stats` 返回 404 | R1 |
|
||||||
|
| 8 | Kubeconfig API 缺失 | **P2** | `GET /clusters/{id}/kubeconfig` 返回 404 | R1 |
|
||||||
|
| 9 | Namespace 静默覆盖 + HTTP 200 | **P2** | 用户部署到他人的 namespace 时,API 返回 201 但 namespace 被静默改为自己的。应返回 403 | R1 |
|
||||||
|
|
||||||
|
### P3 — 低优先级 (6)
|
||||||
|
|
||||||
|
| # | 标题 | 严重度 | 描述 | Round |
|
||||||
|
|----|------|--------|------|-------|
|
||||||
|
| 10 | 用户枚举漏洞 | **P3** | 不存在用户 "user not found" vs 存在用户 "invalid password",错误消息不同 | R1 |
|
||||||
|
| 11 | 无登录速率限制 | **P3** | 10 次快速失败全部返回 401,无 429 或锁定 | R1 |
|
||||||
|
| 12 | Nginx 版本泄露 | **P3** | `Server: nginx/1.27.5` 响应头暴露精确版本 | R1 |
|
||||||
|
| 13 | CORS: * | **P3** | `Access-Control-Allow-Origin: *` 允许任意跨域 | R1 |
|
||||||
|
| 14 | 缺少安全响应头 | **P3** | 无 HSTS, X-Frame-Options, CSP, X-Content-Type-Options | R1 |
|
||||||
|
| 15 | `/health` 端点返回 SPA HTML | **P3** | 健康检查返回 index.html 而非 `{"status":"ok"}` | R1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已修复 (Round 3 验证通过)
|
||||||
|
|
||||||
|
| 原 Bug ID | 描述 | 修复后行为 |
|
||||||
|
|-----------|------|-----------|
|
||||||
|
| BUG-001 | Launch 按钮无反应 (P0) | ✅ 部署端到端正常 |
|
||||||
|
| BUG-002 | SPA 旧路由空白页 (P0) | ✅ 所有旧路由返回 SPA |
|
||||||
|
| BUG-003 | DELETE 返回 404 (P1) | ✅ 返回 HTTP 204 |
|
||||||
|
| BUG-004 | DELETE 空响应体 (P1) | ✅ HTTP 204 No Content |
|
||||||
|
| — | InstanceCard 无 scaling UI | ✅ +/- 按钮 + K8s API |
|
||||||
|
| — | ModifyModal values 为空 | ✅ Full Helm values + diff |
|
||||||
|
| — | Per-card Refresh button | ✅ 移除,改为 page-level |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复优先级排序
|
||||||
|
|
||||||
|
```
|
||||||
|
立即修复 (P1):
|
||||||
|
1. Detail API replicas=0 → 从 K8s live state 同步
|
||||||
|
|
||||||
|
短期修复 (P2):
|
||||||
|
2. API 层配额预检查 → POST instances 时验证
|
||||||
|
3. Values 冲突警告 → 两者同时提供时返回 warning
|
||||||
|
4. Namespace 拒绝而非覆盖 → 返回 403
|
||||||
|
5. 缺失端点实现 (tags/stats/metrics/kubeconfig)
|
||||||
|
|
||||||
|
安全加固 (P3):
|
||||||
|
6. 登录错误消息统一 → "Invalid username or password"
|
||||||
|
7. 速率限制 → max 5/min per IP
|
||||||
|
8. Nginx: server_tokens off + 安全头
|
||||||
|
9. CORS 收紧 → 具体域名
|
||||||
|
10. /health → JSON 响应
|
||||||
|
```
|
||||||
209
docs/regression-full-report.md
Normal file
209
docs/regression-full-report.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
# OCDP 回归测试完整报告 (Round 3)
|
||||||
|
|
||||||
|
**测试日期:** 2026-05-14
|
||||||
|
**环境:** http://10.6.80.114:18080
|
||||||
|
**集群:** k3s v1.28.0 (dbf824f1-9962-4d8e-881e-870c75fdb6f5), k8s (23880994-dfe4-48d0-abc0-b49692cc630a)
|
||||||
|
**Harbor:** harbor.bwgdi.com (83b823af-873b-457c-912c-9ccde3cb12e6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码变更概要 (Commit b88fe24 +)
|
||||||
|
|
||||||
|
| Commit | 变更 |
|
||||||
|
|--------|------|
|
||||||
|
| b88fe24 | fix: real K8s replicas in list API, full Helm values in modify YAML editor |
|
||||||
|
| 96d42ee | fix: scale replicas in response, YAML lineWidth, delta values, modified keys |
|
||||||
|
| 4441f58 | fix: direct K8s scaling, replicas from K8s API, button labels, modify fetch |
|
||||||
|
| 49b92e6 | fix: UI redesign — horizontal instance rows, proper scaling, readable tag cards |
|
||||||
|
| 28ecb2e | feat: scale instances, --reuse-values, values diff, UI redesign, hover animations |
|
||||||
|
| 87eaaa5 | fix: remove per-card Refresh button, consolidate to page-level refresh |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试结果总览
|
||||||
|
|
||||||
|
### 总评分: 85/100
|
||||||
|
|
||||||
|
| 测试领域 | 状态 | 问题数 |
|
||||||
|
|----------|------|--------|
|
||||||
|
| 前端 UI / Launch / Routes | ✅ ALL PASS | 0 |
|
||||||
|
| API CRUD / 部署生命周期 | ⚠️ 2 issues | 2 |
|
||||||
|
| 配额执行 | ⚠️ 1 issue (pre-existing) | 1 |
|
||||||
|
| Values 优先级 / gpuMem | ⚠️ 1 issue | 1 |
|
||||||
|
| 权限隔离 | ✅ ALL PASS | 0 |
|
||||||
|
| 安全测试 | ⚠️ 3 issues (pre-existing) | 3 |
|
||||||
|
| DELETE 行为 | ✅ FIXED | 0 |
|
||||||
|
| InstanceCard / Scaling UI | ✅ ALL PASS | 0 |
|
||||||
|
| ModifyModal / Values Diff | ✅ ALL PASS | 0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新发现 Bug
|
||||||
|
|
||||||
|
### 🆕 BUG-R3-001: Detail API replicas=0 与 List API 不一致 (High)
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 严重度 | **P1 (High)** |
|
||||||
|
| 端点 | `GET /clusters/{id}/instances/{instance_id}` |
|
||||||
|
| 现象 | List API 返回正确 replicas(如 1, 5),但 Detail API 始终返回 `replicas: 0` |
|
||||||
|
| 根因 | Detail endpoint 从数据库读取实例记录,replicas 字段未同步自 K8s 实时状态 |
|
||||||
|
| 影响 | 前端依赖 Detail API 的页面(如刷新后详情页)显示错误的副本数 |
|
||||||
|
| 修复建议 | Detail endpoint 也从 K8s live state 填充 replicas,或确保数据库同步 |
|
||||||
|
|
||||||
|
### 🆕 BUG-R3-002: List API 移除 values 字段
|
||||||
|
|
||||||
|
| 属性 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| 严重度 | **P2 (Medium)** |
|
||||||
|
| 端点 | `GET /clusters/{id}/instances` |
|
||||||
|
| 现象 | List API 响应不再包含 `values` 字段(之前版本有);values 仅在单实例 GET 中返回 |
|
||||||
|
| 影响 | 依赖 list API values 的测试脚本和前端组件会 break |
|
||||||
|
| 备注 | 可能是故意的性能优化,但属于 API 行为变更 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已修复 Bug (验证通过)
|
||||||
|
|
||||||
|
| Bug ID | 描述 | 之前状态 | 现在状态 | 验证 |
|
||||||
|
|--------|------|----------|----------|------|
|
||||||
|
| BUG-001 | Launch 按钮无反应 | P0 Blocker | ✅ FIXED | 部署端到端成功 |
|
||||||
|
| BUG-002 | SPA 旧路由空白页 | P0 Blocker | ✅ FIXED | 所有旧路由返回 SPA |
|
||||||
|
| BUG-003 | DELETE 返回 404 | P1 High | ✅ FIXED | 返回 HTTP 204 |
|
||||||
|
| BUG-004 | DELETE 空 body | P1 High | ✅ FIXED | HTTP 204 No Content |
|
||||||
|
| - | InstanceCard 无 scaling UI | New Feature | ✅ ADDED | +/- 按钮 + API 调用 |
|
||||||
|
| - | ModifyModal values 为空 | Bug | ✅ FIXED | Full Helm values 返回 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 仍未修复的已知问题
|
||||||
|
|
||||||
|
| Bug ID | 描述 | 严重度 | 状态 |
|
||||||
|
|--------|------|--------|------|
|
||||||
|
| BUG-013 | 用户枚举(login 错误消息差异) | Medium | 未修复 |
|
||||||
|
| BUG-014 | 无速率限制 | Medium | 未修复 |
|
||||||
|
| BUG-015 | Nginx 版本泄露 | Low | 未修复 |
|
||||||
|
| BUG-016 | CORS: * | Low | 未修复 |
|
||||||
|
| BUG-017 | 缺少安全响应头 | Low | 未修复 |
|
||||||
|
| BUG-018 | /health 返回 HTML | Low | 未修复 |
|
||||||
|
| BUG-005 | Tags 端点 404 | Medium | 未修复 |
|
||||||
|
| BUG-006 | Namespace 静默覆盖无警告 | Medium | 未修复 |
|
||||||
|
| BUG-007-009 | Metrics/Stats/Kubeconfig 端点缺失 | Medium | 未修复 |
|
||||||
|
| BUG-011 | API 响应格式不一致 | Low | 未修复 |
|
||||||
|
| BUG-012 | /auth/me 空 token 字段 | Low | 未修复 |
|
||||||
|
| - | API 层配额预检查 | New Feature | 未实现 |
|
||||||
|
| - | Values 冲突时无警告 | UX | 未修复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 详细测试结果
|
||||||
|
|
||||||
|
### 1. 前端 UI (test-user-a)
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| 登录页加载 | ✅ HTTP 200, SPA 398 bytes |
|
||||||
|
| JWT 认证 | ✅ role=user, 10 permissions, quota 字段 |
|
||||||
|
| 所有页面路由 | ✅ 6 个路由全部返回 398 bytes (非空白) |
|
||||||
|
| 旧路由重定向 | ✅ /clusters, /registries, /launch, /monitoring 全部返回 SPA |
|
||||||
|
| Chart Browser | ✅ nginx:22.1.1 可发现,Launch 按钮可用 |
|
||||||
|
| 部署 Pipeline | ✅ pending-install → deployed (~15s) |
|
||||||
|
| InstanceCard Scale UI | ✅ +/- 按钮,副本数显示 |
|
||||||
|
| InstanceCard Actions | ✅ Entries/Diag/Modify/Delete 全部可见 |
|
||||||
|
| ModifyModal YAML Editor | ✅ full Helm values, lineWidth:0, diff 检测 |
|
||||||
|
| TagCard UI | ✅ 色标 (chart=blue, image=green), Copy helm pull 命令, LATEST badge |
|
||||||
|
| DELETE | ✅ HTTP 204 |
|
||||||
|
|
||||||
|
### 2. API 后端 (test-user-b)
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| Login + /auth/me | ✅ 完整 profile (quota, namespace, permissions) |
|
||||||
|
| Cluster list | ✅ 2 clusters |
|
||||||
|
| Registry list | ✅ 1 registry (harbor-bwgdi) |
|
||||||
|
| Repository artifacts | ✅ Harbor API proxy 正常 |
|
||||||
|
| Cluster health | ✅ k3s healthy, v1.28.0 |
|
||||||
|
| 部署 nginx (default) | ✅ deployed, replicas=1 |
|
||||||
|
| 部署 nginx (over-quota) | ⚠️ 接受部署 (API 无预检查) |
|
||||||
|
| 实例状态轮询 | ✅ ~20s 到达 deployed |
|
||||||
|
| List API replicas | ✅ 正确显示 1/5 |
|
||||||
|
| Detail API replicas | ❌ 返回 0 (BUG-R3-001) |
|
||||||
|
| 实例删除 | ✅ HTTP 204 (BUG-003 FIXED) |
|
||||||
|
| 缺失端点 | ✅ 正常 404 |
|
||||||
|
|
||||||
|
### 3. Values 优先级 (test-user-c)
|
||||||
|
|
||||||
|
| 方法 | 结果 |
|
||||||
|
|------|------|
|
||||||
|
| values JSON only | ✅ gpuMem=10000 正确接受 |
|
||||||
|
| valuesYaml only | ✅ YAML -> JSON 解析正确 |
|
||||||
|
| 同时提供 (冲突) | ✅ values JSON 覆盖 valuesYaml |
|
||||||
|
| 冲突警告 | ❌ 无警告(建议添加) |
|
||||||
|
| 默认值 (空 values) | ✅ 使用 chart 内置 defaults |
|
||||||
|
| gpuMem=10000 | ✅ 整数 MB scalar 正确 |
|
||||||
|
| 清理 | ✅ 所有 3 个实例已删除 |
|
||||||
|
|
||||||
|
### 4. 权限隔离
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| Admin lists users | ✅ 10 users |
|
||||||
|
| test-admin-d lists users | ✅ 10 users (admin role works) |
|
||||||
|
| test-user-c GET /users | ✅ 403 Forbidden |
|
||||||
|
| test-user-c POST /auth/register | ✅ 403 Forbidden |
|
||||||
|
| Cross-tenant deploy (c→b) | ✅ Silent override to ocdp-u-test-c (secure) |
|
||||||
|
| Deploy into own namespace | ✅ 成功 |
|
||||||
|
| Instance visibility | ✅ 仅看到自己的实例 |
|
||||||
|
| Disable user | ✅ 用户被禁用 |
|
||||||
|
| Disabled user login | ✅ 401 |
|
||||||
|
| Re-enable user | ✅ 恢复 |
|
||||||
|
| Self-registration | ✅ 401 (必须认证) |
|
||||||
|
|
||||||
|
### 5. 安全 (回归)
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|--------|------|
|
||||||
|
| 未认证端点 | ✅ 全部 401 |
|
||||||
|
| JWT 验证 | ✅ 篡改 token 被拒绝 |
|
||||||
|
| XSS/SQLi | ✅ 安全处理 |
|
||||||
|
| 敏感数据脱敏 | ✅ creds 显示为 •••••••• |
|
||||||
|
| 普通用户权限提升 | ✅ 403 blocked |
|
||||||
|
| 用户枚举 | ❌ 不同错误消息 (未修复) |
|
||||||
|
| 速率限制 | ❌ 无 429 (未修复) |
|
||||||
|
| CORS * + 缺失安全头 | ❌ 未修复 |
|
||||||
|
| Nginx 版本泄露 | ❌ "nginx/1.27.5" (未修复) |
|
||||||
|
| /auth/me 空 token | ✅ tokens 已正确清空 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 优先修复清单
|
||||||
|
|
||||||
|
### 立即修复 (P0/P1)
|
||||||
|
1. **BUG-R3-001**: Detail API replicas=0 — 从 K8s live state 填充
|
||||||
|
2. **配额预检查** — POST instances 时验证请求资源 ≤ 用户配额
|
||||||
|
|
||||||
|
### 尽快修复 (P2)
|
||||||
|
3. Values 冲突时添加 API 警告
|
||||||
|
4. Tags/Metrics/Stats/Kubeconfig 端点实现
|
||||||
|
5. Namespace 静默覆盖返回警告
|
||||||
|
6. List API values 字段回归或文档化
|
||||||
|
|
||||||
|
### 安全加固 (P3)
|
||||||
|
7. 登录错误消息统一
|
||||||
|
8. 速率限制
|
||||||
|
9. Nginx 安全头 + 关闭 server_tokens
|
||||||
|
10. CORS 收紧
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 对比: Round 1 vs Round 3
|
||||||
|
|
||||||
|
| 指标 | Round 1 (2026-05-11) | Round 3 (2026-05-14) |
|
||||||
|
|------|---------------------|---------------------|
|
||||||
|
| Total Bugs | 18 | 15 (3 fixed, 12 remain, 2 new) |
|
||||||
|
| P0 Blockers | 2 (Launch, Routes) | 0 |
|
||||||
|
| P1 High | 2 (DELETE 404, empty body) | 1 (Detail replicas=0) |
|
||||||
|
| 新功能 | - | Scaling UI, Values Diff, YAML editor |
|
||||||
|
| 安全漏洞 | 6 | 5 (token fields fixed) |
|
||||||
|
|
||||||
|
**结论:** 代码修改有效,3 个关键 Bug 已修复,新增了 scaling 和 values diff 功能。仍有 12 个已知问题和 2 个新问题待修复。
|
||||||
1640
docs/test-scenarios.md
Normal file
1640
docs/test-scenarios.md
Normal file
File diff suppressed because it is too large
Load Diff
79
docs/test-users.json
Normal file
79
docs/test-users.json
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"createdAt": "2026-05-11T09:58:00Z",
|
||||||
|
"apiBase": "http://10.6.80.114:18080/api/v1",
|
||||||
|
"adminUsername": "admin",
|
||||||
|
"adminPassword": "admin123"
|
||||||
|
},
|
||||||
|
"existingResources": {
|
||||||
|
"clusters": {
|
||||||
|
"k8s": {
|
||||||
|
"id": "23880994-dfe4-48d0-abc0-b49692cc630a",
|
||||||
|
"host": "https://10.6.80.12:6443"
|
||||||
|
},
|
||||||
|
"k3s": {
|
||||||
|
"id": "dbf824f1-9962-4d8e-881e-870c75fdb6f5",
|
||||||
|
"host": "https://10.6.80.23:6443"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"registries": {
|
||||||
|
"harbor-bwgdi": {
|
||||||
|
"id": "83b823af-873b-457c-912c-9ccde3cb12e6",
|
||||||
|
"url": "https://harbor.bwgdi.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testUsers": [
|
||||||
|
{
|
||||||
|
"id": "0c70fce6-fa69-4231-979a-5970ff9b854b",
|
||||||
|
"username": "test-user-a",
|
||||||
|
"password": "TestUserA123!",
|
||||||
|
"email": "test-user-a@local.ocdp",
|
||||||
|
"role": "user",
|
||||||
|
"purpose": "Frontend UI testing",
|
||||||
|
"namespace": "ocdp-u-test-a",
|
||||||
|
"defaultClusterId": "dbf824f1-9962-4d8e-881e-870c75fdb6f5",
|
||||||
|
"quotaCpu": "4",
|
||||||
|
"quotaMemory": "8Gi",
|
||||||
|
"quotaGpu": "1",
|
||||||
|
"quotaGpuMemory": "5000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "819b12ec-718e-48be-92bc-0cd1f7205926",
|
||||||
|
"username": "test-user-b",
|
||||||
|
"password": "TestUserB123!",
|
||||||
|
"email": "test-user-b@local.ocdp",
|
||||||
|
"role": "user",
|
||||||
|
"purpose": "API/deploy testing",
|
||||||
|
"namespace": "ocdp-u-test-b",
|
||||||
|
"defaultClusterId": "dbf824f1-9962-4d8e-881e-870c75fdb6f5",
|
||||||
|
"quotaCpu": "2",
|
||||||
|
"quotaMemory": "4Gi",
|
||||||
|
"quotaGpu": "0",
|
||||||
|
"quotaGpuMemory": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "04ef67ba-49c2-44e2-87b4-b71b5d9f36dc",
|
||||||
|
"username": "test-user-c",
|
||||||
|
"password": "TestUserC123!",
|
||||||
|
"email": "test-user-c@local.ocdp",
|
||||||
|
"role": "user",
|
||||||
|
"purpose": "Permission isolation testing",
|
||||||
|
"namespace": "ocdp-u-test-c",
|
||||||
|
"defaultClusterId": "dbf824f1-9962-4d8e-881e-870c75fdb6f5",
|
||||||
|
"quotaCpu": "4",
|
||||||
|
"quotaMemory": "8Gi",
|
||||||
|
"quotaGpu": "1",
|
||||||
|
"quotaGpuMemory": "5000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8bcffd0e-4e7a-4e9a-a47b-bfdb463698c2",
|
||||||
|
"username": "test-admin-d",
|
||||||
|
"password": "TestAdminD123!",
|
||||||
|
"email": "test-admin-d@local.ocdp",
|
||||||
|
"role": "admin",
|
||||||
|
"purpose": "Admin features testing",
|
||||||
|
"namespace": "ocdp-ws-default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
752
docs/user-guide.md
Normal file
752
docs/user-guide.md
Normal file
@ -0,0 +1,752 @@
|
|||||||
|
# OCDP Platform User Guide
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#1-overview)
|
||||||
|
2. [Login / Authentication](#2-login--authentication)
|
||||||
|
3. [Home Page](#3-home-page)
|
||||||
|
4. [Launch Instance (Chart Browser)](#4-launch-instance-chart-browser)
|
||||||
|
5. [Instances Management](#5-instances-management)
|
||||||
|
6. [Cluster Monitoring](#6-cluster-monitoring)
|
||||||
|
7. [Setup — Clusters](#7-setup--clusters)
|
||||||
|
8. [Setup — Registries](#8-setup--registries)
|
||||||
|
9. [Setup — Users (Admin)](#9-setup--users-admin)
|
||||||
|
10. [Navigation](#10-navigation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
**OCDP (Open Cloud Deployment Platform)** is a Kubernetes LLM inference deployment platform. Its primary use case is: a user selects a `vllm-serve` Helm chart from a Harbor registry, fills in the instance name, namespace, and values, and the backend pulls the packaged OCI Helm chart and deploys it to a configured Kubernetes cluster via the Helm SDK.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend (React 18 + TypeScript + Vite + TailwindCSS)
|
||||||
|
|
|
||||||
|
| HTTP /api/*
|
||||||
|
v
|
||||||
|
Nginx (Reverse Proxy / Static File Server)
|
||||||
|
|
|
||||||
|
| HTTP /api/*
|
||||||
|
v
|
||||||
|
Backend (Go 1.24 + Gorilla Mux + Hexagonal Architecture)
|
||||||
|
|
|
||||||
|
+---> PostgreSQL (persistence)
|
||||||
|
+---> ORAS SDK (OCI chart pull)
|
||||||
|
+---> Helm SDK (deploy/upgrade/delete)
|
||||||
|
+---> client-go (Kubernetes API)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------------|-----------------------------------------------------------------|
|
||||||
|
| Frontend | React 18, TypeScript, Vite, TailwindCSS, React Router, Lucide icons |
|
||||||
|
| Backend | Go 1.24, Gorilla Mux, PostgreSQL, ORAS SDK, Helm SDK, client-go |
|
||||||
|
| Gateway | Nginx (reverse proxy + static file serving) |
|
||||||
|
| Database | PostgreSQL |
|
||||||
|
| Deployment | Docker Compose |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Login / Authentication
|
||||||
|
|
||||||
|
### Access
|
||||||
|
|
||||||
|
The frontend is deployed at `http://10.6.80.114:18080`. Navigating to the root URL redirects to the login page.
|
||||||
|
|
||||||
|
### Login Page
|
||||||
|
|
||||||
|
The login page displays:
|
||||||
|
|
||||||
|
- **OCDP Console** title with a shield icon
|
||||||
|
- Subtitle: "Sign in with an account created by an administrator"
|
||||||
|
- **Username** text field (required)
|
||||||
|
- **Password** text field (required, masked)
|
||||||
|
- **Login** button — blue, centered, full-width
|
||||||
|
|
||||||
|
When you click **Login**:
|
||||||
|
|
||||||
|
1. A toast notification "Logging in..." appears briefly
|
||||||
|
2. The button shows a spinning loader and "Logging in..." text
|
||||||
|
3. On success: a "Welcome, {username}!" toast appears, and you are redirected to `/home`
|
||||||
|
4. On failure: a red error message is shown below the button (e.g., "Invalid credentials" or "Network error")
|
||||||
|
|
||||||
|
### Default Admin Credentials
|
||||||
|
|
||||||
|
If the system was bootstrapped via `.env` configuration, the default admin credentials are:
|
||||||
|
|
||||||
|
- **Username:** `admin` (or whatever was set as `BOOTSTRAP_ADMIN_USER`)
|
||||||
|
- **Password:** The value of `BOOTSTRAP_ADMIN_PASS` in your `.env` file
|
||||||
|
|
||||||
|
### JWT Session Behavior
|
||||||
|
|
||||||
|
- The backend issues JWT tokens upon successful login
|
||||||
|
- The frontend stores the tokens and sends them as `Authorization: Bearer <token>` headers
|
||||||
|
- Session persists until the token expires or the user signs out
|
||||||
|
- Clicking the **logout icon** (top-right corner, person icon with a door arrow) signs the user out
|
||||||
|
|
||||||
|
### Routing When Authenticated
|
||||||
|
|
||||||
|
- Unauthenticated users are always redirected to `/` (login page)
|
||||||
|
- Authenticated users visiting `/` are redirected to `/home`
|
||||||
|
- Protected routes are wrapped in a `ProtectedRoute` component; unauthorized access redirects to login
|
||||||
|
- Route-level access is enforced per user role (admin vs regular user)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Home Page
|
||||||
|
|
||||||
|
The home page at `/home` is the main landing page after login. It has three sections.
|
||||||
|
|
||||||
|
### Section 1: Primary Actions (3 Cards)
|
||||||
|
|
||||||
|
A large card titled "One Click Deployment Platform" / "Operations Workbench" contains three action cards arranged in a row:
|
||||||
|
|
||||||
|
**1. Launch Instance**
|
||||||
|
- Icon: Rocket (blue background)
|
||||||
|
- Description: "Browse Helm charts and deploy a new inference service."
|
||||||
|
- Clicking navigates to `/artifact/registries`
|
||||||
|
- Shows "Open" with an arrow on hover
|
||||||
|
|
||||||
|
**2. Instances**
|
||||||
|
- Icon: Package (emerald background)
|
||||||
|
- Description: "Check release status, entries, upgrades, and deletion."
|
||||||
|
- Clicking navigates to `/artifact/instances`
|
||||||
|
- Shows "Open" with an arrow on hover
|
||||||
|
|
||||||
|
**3. Cluster Monitoring**
|
||||||
|
- Icon: Activity (dark slate background)
|
||||||
|
- Description: "Inspect cluster health and node resource pressure."
|
||||||
|
- Clicking navigates to `/monitoring/clusters`
|
||||||
|
- Shows "Open" with an arrow on hover
|
||||||
|
|
||||||
|
Each card:
|
||||||
|
- Has a subtle border, slate background, and hover effect (lifts slightly, adds blue border)
|
||||||
|
- Shows a colored icon box at the top
|
||||||
|
- Shows title and description
|
||||||
|
- Has an "Open" link with right-arrow at the bottom
|
||||||
|
|
||||||
|
### Section 2: Runtime Focus Sidebar
|
||||||
|
|
||||||
|
On the right side of the primary actions, a smaller card titled "Runtime Focus" with subtitle "High-frequency checks":
|
||||||
|
|
||||||
|
- **Release status** — clickable row that navigates to `/artifact/instances`. Subtitle: "Installed, failed, deleting"
|
||||||
|
- **Cluster health** — clickable row that navigates to `/monitoring/clusters`. Subtitle: "Nodes, pods, CPU, memory"
|
||||||
|
|
||||||
|
### Section 3: Setup
|
||||||
|
|
||||||
|
A bottom section titled "Setup" with subtitle "Less frequent administrative tasks". Contains three buttons in a row:
|
||||||
|
|
||||||
|
1. **Clusters** — Server icon. Description: "Kubeconfig and namespace policy". Navigates to `/configuration/clusters`
|
||||||
|
2. **Registries** — Database icon. Description: "Harbor robot account and chart access". Navigates to `/configuration/registries`
|
||||||
|
3. **Users** — Users icon. Description: "Admin-only account management". Navigates to `/configuration/users`. Only visible to admin users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Launch Instance (Chart Browser)
|
||||||
|
|
||||||
|
The Artifact Browser page at `/artifact/registries` is the chart browser for selecting and launching Helm charts.
|
||||||
|
|
||||||
|
### Page Layout
|
||||||
|
|
||||||
|
The page is a split-pane layout:
|
||||||
|
- **Left sidebar (w-80):** Registry tree with search
|
||||||
|
- **Right main panel:** Repository info, tags, and launch actions
|
||||||
|
|
||||||
|
### Left Panel: Registry Tree
|
||||||
|
|
||||||
|
**Header bar** (top of page):
|
||||||
|
- Title: "Chart Browser"
|
||||||
|
- Subtitle: "Select a Harbor chart and launch it into a Kubernetes cluster"
|
||||||
|
- **Refresh** button (secondary style, refresh icon) — reloads all registries and repositories, clears cache
|
||||||
|
|
||||||
|
**Search bar** at the top of the sidebar:
|
||||||
|
- Placeholder: "Search registries / repositories..."
|
||||||
|
- Filters the tree as you type (matches registry name and repository name)
|
||||||
|
- Shows a search icon on the left
|
||||||
|
|
||||||
|
**Registry nodes** listed below the search bar:
|
||||||
|
- Each registry shows:
|
||||||
|
- Chevron (down/right) to expand/collapse
|
||||||
|
- Database icon (blue)
|
||||||
|
- Registry name
|
||||||
|
- Registry URL (truncated, small text)
|
||||||
|
- Badge showing count of repositories
|
||||||
|
- Registries are expanded by default
|
||||||
|
- Clicking a registry header toggles expansion
|
||||||
|
|
||||||
|
**Repository items** under each registry:
|
||||||
|
- Each shows the repository name
|
||||||
|
- Clicking a repository selects it (highlighted blue background) and loads its artifacts in the right panel
|
||||||
|
- Shows artifact count if available
|
||||||
|
- If no repositories: shows "No chart repositories found."
|
||||||
|
- If loading: shows "Loading repositories..."
|
||||||
|
|
||||||
|
### Right Panel: Repository Details
|
||||||
|
|
||||||
|
When a repository is selected:
|
||||||
|
|
||||||
|
**Repository header:**
|
||||||
|
- Label: "Chart repository" (uppercase, small)
|
||||||
|
- Repository name (large, bold)
|
||||||
|
- Registry name below
|
||||||
|
- **Filter chips:** Two toggle buttons: "Charts" (default selected, blue) and "All tags"
|
||||||
|
- "Charts" filter shows only artifacts of type `chart` (i.e., deployable Helm charts)
|
||||||
|
- "All tags" shows every artifact version regardless of type
|
||||||
|
|
||||||
|
**Artifact grid** (responsive: 1-3 columns):
|
||||||
|
- Each artifact is displayed in a **TagCard** component (see below)
|
||||||
|
|
||||||
|
When no repository is selected:
|
||||||
|
- Shows empty state: "Select a repository" with "Choose a chart repository from the left panel."
|
||||||
|
|
||||||
|
### TagCard Component
|
||||||
|
|
||||||
|
Each TagCard shows:
|
||||||
|
|
||||||
|
- **Type icon**: Package (chart), Box (image), or File (other) with color-coded background
|
||||||
|
- **Tag name** (e.g., `1.0.0`) with a type badge (e.g., "chart" in blue)
|
||||||
|
- **Repository path** (truncated)
|
||||||
|
- **Size** in KB or MB (e.g., "12.5 MB")
|
||||||
|
|
||||||
|
**TagCard buttons:**
|
||||||
|
|
||||||
|
1. **Launch button** — Blue button, only visible when the artifact type is `chart`. Shows rocket icon + "Launch". Opens the LaunchModal.
|
||||||
|
2. **Copy button** — White button with copy icon. Copies the `helm pull oci://...` command to the clipboard. Shows a success toast.
|
||||||
|
|
||||||
|
### LaunchModal
|
||||||
|
|
||||||
|
Opens when "Launch" is clicked on a chart artifact. Title: "Launch Instance" with rocket icon.
|
||||||
|
|
||||||
|
**Modal header:**
|
||||||
|
- Shows the repository name and tag (e.g., `vllm-serve:1.0.0`)
|
||||||
|
|
||||||
|
**Form fields:**
|
||||||
|
|
||||||
|
1. **Target Cluster** (required)
|
||||||
|
- Dropdown select listing all configured clusters
|
||||||
|
- Auto-selects the first available cluster (or user's default cluster)
|
||||||
|
- If no clusters: shows an amber warning "No clusters available. Please add a cluster first."
|
||||||
|
- Shows loading state while fetching
|
||||||
|
|
||||||
|
2. **Instance Name** (required)
|
||||||
|
- Text input, placeholder: "my-app"
|
||||||
|
- Help text: "Lowercase alphanumeric characters, '-' or '.'"
|
||||||
|
|
||||||
|
3. **Namespace** (required)
|
||||||
|
- If the selected cluster has allowed namespaces: shows a dropdown of allowed namespaces
|
||||||
|
- If no restrictions: shows a text input, default "default"
|
||||||
|
- If namespace is controlled by workspace policy: input is disabled with a blue info notice
|
||||||
|
|
||||||
|
4. **Description** (optional)
|
||||||
|
- Text input, placeholder: "Optional description"
|
||||||
|
|
||||||
|
5. **Configuration Values** — Three input modes:
|
||||||
|
|
||||||
|
**a) Quick mode** (default):
|
||||||
|
- Blue info box explaining "Quick launch uses the chart defaults"
|
||||||
|
- Shows badges: "No values override" and if available "Chart values.yaml available"
|
||||||
|
- If `values.yaml` exists: "Load Defaults from values.yaml" button switches to YAML mode with defaults pre-filled
|
||||||
|
- Best for simple deployments with no custom overrides
|
||||||
|
|
||||||
|
**b) Guided mode** (form):
|
||||||
|
- Only available when the chart provides a JSON Schema for its values
|
||||||
|
- Dynamically generates form fields based on the schema
|
||||||
|
- Supports various schema types: string, number, boolean, object, array
|
||||||
|
- **"Load Defaults"** button — fills in values from the schema defaults
|
||||||
|
- Shows schema-generated form in a scrollable container
|
||||||
|
|
||||||
|
**c) YAML mode**:
|
||||||
|
- A code editor (textarea) for entering custom values in YAML format
|
||||||
|
- Real-time YAML validation with error display
|
||||||
|
- "Load Defaults from values.yaml" button
|
||||||
|
- "Load Schema Defaults" button (if no values.yaml but schema exists)
|
||||||
|
- "Clear" button to reset the YAML
|
||||||
|
- Help text changes based on whether schema is available
|
||||||
|
|
||||||
|
6. **Artifact Info** (read-only summary):
|
||||||
|
- Repository name
|
||||||
|
- Tag (badge)
|
||||||
|
- Type
|
||||||
|
|
||||||
|
**Footer buttons:**
|
||||||
|
- **Cancel** (secondary) — closes the modal
|
||||||
|
- **Launch** (success/green style with rocket icon) — submits the deployment
|
||||||
|
|
||||||
|
**Validation on submit:**
|
||||||
|
- Cluster must be selected
|
||||||
|
- Instance name must not be empty
|
||||||
|
- Namespace must not be empty
|
||||||
|
- If namespace policy restricts namespaces, the selected namespace must be in the allowed list
|
||||||
|
- YAML values are parsed and validated before submission
|
||||||
|
|
||||||
|
**After successful submit:**
|
||||||
|
- Form resets
|
||||||
|
- Modal closes
|
||||||
|
- Navigates to `/artifact/instances` to show the deploying instance
|
||||||
|
- Shows "Instance deployed successfully" toast
|
||||||
|
|
||||||
|
**Error states:**
|
||||||
|
- Loading clusters fails: error toast
|
||||||
|
- Missing required fields: validation error toast
|
||||||
|
- YAML parse error: inline error + toast
|
||||||
|
- API failure: error toast with message
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Instances Management
|
||||||
|
|
||||||
|
The Instances page at `/artifact/instances` manages all deployed Helm releases across clusters.
|
||||||
|
|
||||||
|
### Stats Cards
|
||||||
|
|
||||||
|
Three gradient stat cards at the top (shown when clusters exist):
|
||||||
|
|
||||||
|
1. **Total Instances** (blue) — total count across all clusters
|
||||||
|
2. **Clusters** (emerald) — number of clusters
|
||||||
|
3. **Showing** (violet) — count of currently displayed instances (only shown when filtering across 2+ clusters)
|
||||||
|
|
||||||
|
### Filter Controls
|
||||||
|
|
||||||
|
When more than one cluster exists, a filter bar appears:
|
||||||
|
- **Filter by Cluster** dropdown with "All Clusters" and each cluster with instance count
|
||||||
|
- Selecting a cluster filters the instance list to that cluster only
|
||||||
|
|
||||||
|
### Instance Display
|
||||||
|
|
||||||
|
Instances are grouped by cluster when "All Clusters" is selected, each cluster section showing:
|
||||||
|
- Cluster name with instance count
|
||||||
|
- Instances in a responsive 2-column grid
|
||||||
|
|
||||||
|
### InstanceCard Component
|
||||||
|
|
||||||
|
Each card shows:
|
||||||
|
|
||||||
|
**Header:**
|
||||||
|
- Instance name (bold, large)
|
||||||
|
- Repository name with version badge (cyan)
|
||||||
|
- **Status badge** with colored background glow:
|
||||||
|
|
||||||
|
| Status | Badge Color | Description |
|
||||||
|
|-----------------|-------------|----------------------------------------------------|
|
||||||
|
| Deployed | Emerald | Deployment completed successfully |
|
||||||
|
| Failed | Rose/Red | Last operation reported a failure |
|
||||||
|
| Pending Install | Amber | Installation is in progress |
|
||||||
|
| Pending Upgrade | Amber | Upgrade is in progress |
|
||||||
|
| Pending Rollback| Amber | Rollback is in progress |
|
||||||
|
| Pending Delete | Orange | Deletion is in progress |
|
||||||
|
| Superseded | Indigo | A newer revision has replaced this instance |
|
||||||
|
| Uninstalled | Slate | Instance has been removed from the cluster |
|
||||||
|
| Unknown | Slate | Awaiting next state update |
|
||||||
|
|
||||||
|
- Status reason text (e.g., "Deployment completed successfully." or a custom message)
|
||||||
|
- Last operation label (Install / Upgrade / Rollback / Delete / Sync)
|
||||||
|
|
||||||
|
**Details grid:**
|
||||||
|
- **Namespace** — purple icon
|
||||||
|
- **Revision** — green icon, Helm revision number
|
||||||
|
- **Repository** — full-width, truncated, monospace
|
||||||
|
- **Launched** — date the instance was created
|
||||||
|
|
||||||
|
**Last error alert** (conditionally shown):
|
||||||
|
- Red alert box with warning icon
|
||||||
|
- Shows the last error message if the instance encountered errors
|
||||||
|
|
||||||
|
**Action buttons (5 buttons in a row):**
|
||||||
|
|
||||||
|
1. **Refresh** — Refresh icon. Refreshes the status of this specific instance from the cluster.
|
||||||
|
2. **Entries** — Network icon (emerald). Opens Entries modal.
|
||||||
|
3. **Diagnostics** — Activity icon (indigo). Opens Diagnostics modal.
|
||||||
|
4. **Modify** — Settings icon (blue). Opens Modify modal.
|
||||||
|
5. **Delete** — Stop icon (rose/red). Prompts confirmation, then deletes.
|
||||||
|
|
||||||
|
### Empty / Loading / Error States
|
||||||
|
|
||||||
|
- **Loading:** Shows spinning indicator with "Loading instances..."
|
||||||
|
- **Error:** Shows error state with retry button
|
||||||
|
- **Empty:** "No instances found" with link to launch from registries
|
||||||
|
- **Auto-refresh:** Data refreshes every 30 seconds silently
|
||||||
|
|
||||||
|
### Entries Modal
|
||||||
|
|
||||||
|
Displays network entry information for the instance.
|
||||||
|
|
||||||
|
**Header:**
|
||||||
|
- Title: "Instance Entries"
|
||||||
|
- Instance name and namespace
|
||||||
|
|
||||||
|
**Data Source badge:**
|
||||||
|
- **Live from Kubernetes** (green) — fetched directly from the cluster
|
||||||
|
- **From Helm Manifest** (blue) — extracted from Helm manifest
|
||||||
|
- **From Helm Notes** (yellow) — from Helm release notes
|
||||||
|
- **No Data Available** (gray)
|
||||||
|
|
||||||
|
**Services section** (if any):
|
||||||
|
- Lists each Kubernetes Service with:
|
||||||
|
- Service name and type badge
|
||||||
|
- Cluster IP (copyable)
|
||||||
|
- Ports with mapping (e.g., `80 → 8080 TCP`, NodePort)
|
||||||
|
- LoadBalancer entries (if applicable) with external link and copy
|
||||||
|
|
||||||
|
**Ingresses section** (if any):
|
||||||
|
- Lists each Kubernetes Ingress with:
|
||||||
|
- Ingress name and class
|
||||||
|
- Host with external link and copy buttons
|
||||||
|
- Path routing (e.g., `/ → service:80`)
|
||||||
|
- TLS indicator if HTTPS is configured
|
||||||
|
|
||||||
|
**Helm Notes** (as fallback):
|
||||||
|
- Raw Helm notes text shown in a monospace pre block
|
||||||
|
|
||||||
|
**Footer:** Close button
|
||||||
|
|
||||||
|
### Diagnostics Modal
|
||||||
|
|
||||||
|
Provides Kubernetes-level diagnostics for the instance.
|
||||||
|
|
||||||
|
**Header:**
|
||||||
|
- "Runtime diagnostics" label
|
||||||
|
- Instance name
|
||||||
|
- Namespace and data collection timestamp
|
||||||
|
|
||||||
|
**Refresh button** in the header — reloads diagnostics data from Kubernetes
|
||||||
|
|
||||||
|
**Three tabs:**
|
||||||
|
|
||||||
|
**1. Describe tab** (default):
|
||||||
|
- Summary metrics: Pods count, Services count, Events count
|
||||||
|
- **Pods section** — each pod shows:
|
||||||
|
- Pod name, node, pod IP, restart count
|
||||||
|
- Status badge (Running=success, other=warning)
|
||||||
|
- Containers with name, state badge, image, and reason/message
|
||||||
|
- **Services section** — each service with name, type badge, ClusterIP, ports
|
||||||
|
|
||||||
|
**2. Events tab**:
|
||||||
|
- Kubernetes events sorted by time
|
||||||
|
- Each event shows: type badge (Warning/ Normal), reason, timestamp, message, involved object, count
|
||||||
|
|
||||||
|
**3. Pod Logs tab**:
|
||||||
|
- Logs from each container, labeled by pod/container name
|
||||||
|
- Monospace display on dark background
|
||||||
|
- **Copy Logs** button copies all logs to clipboard
|
||||||
|
- Last 300 lines are fetched per container
|
||||||
|
|
||||||
|
**Error states:**
|
||||||
|
- Loading fails: error toast
|
||||||
|
- No data: amber info box "Diagnostics data is not available"
|
||||||
|
- Empty pods/events/logs: relevant empty state message
|
||||||
|
|
||||||
|
### Modify Modal
|
||||||
|
|
||||||
|
Allows modifying an existing instance.
|
||||||
|
|
||||||
|
**Header:** "Modify Instance - {name}" with settings icon
|
||||||
|
|
||||||
|
**Current info section** (read-only):
|
||||||
|
- Current version
|
||||||
|
- Cluster ID
|
||||||
|
- Repository
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
1. **Version Tag** — text input, pre-filled with current version. Help: "Leave unchanged to keep current version"
|
||||||
|
2. **Description** — text input
|
||||||
|
3. **Configuration Values** — Form or YAML mode (auto-detects if schema exists):
|
||||||
|
- **Form mode:** Dynamic form generated from values schema, with real-time sync to YAML
|
||||||
|
- **YAML mode:** Textarea with monospace font, pre-filled with current values
|
||||||
|
|
||||||
|
**Footer:** Cancel / Modify buttons
|
||||||
|
|
||||||
|
**After submit:** Instance is upgraded via Helm, data refreshes, modal closes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cluster Monitoring
|
||||||
|
|
||||||
|
The Monitoring page at `/monitoring/clusters` shows the health and resource usage of all configured Kubernetes clusters.
|
||||||
|
|
||||||
|
### Summary Stats Cards
|
||||||
|
|
||||||
|
Four stat cards at the top:
|
||||||
|
|
||||||
|
1. **Total Clusters** (blue) — total number of clusters
|
||||||
|
2. **Healthy** (green) — clusters with status "healthy"
|
||||||
|
3. **Warning** (orange) — clusters with status "warning" or "unknown"
|
||||||
|
4. **Error** (red) — clusters with status "error" or "unhealthy"
|
||||||
|
|
||||||
|
### Auto-Refresh
|
||||||
|
|
||||||
|
- The page auto-refreshes every **30 seconds**
|
||||||
|
- A small note shows "Auto-refresh every 30 seconds" with a refresh indicator
|
||||||
|
- Manual **Refresh** button in the page header
|
||||||
|
|
||||||
|
### ClusterMonitorCard
|
||||||
|
|
||||||
|
Each cluster is shown in an expandable card.
|
||||||
|
|
||||||
|
**Card header:**
|
||||||
|
- Status icon (green check, yellow warning, red X, or gray question mark)
|
||||||
|
- Cluster name with status badge (Healthy / Warning / Error)
|
||||||
|
|
||||||
|
**Metrics grid** (4 columns):
|
||||||
|
- **Uptime** — how long the cluster has been running
|
||||||
|
- **Nodes** — node count
|
||||||
|
- **Pods** — pod count
|
||||||
|
- **GPU** — used/total GPU count
|
||||||
|
|
||||||
|
**Resource usage** (3 columns with progress bars):
|
||||||
|
- **CPU** — used/total, percentage bar, max per node with peak usage
|
||||||
|
- **Memory** — used/total, percentage bar, max per node with peak usage
|
||||||
|
- **GPU** (only if GPUs exist) — used/total, percentage bar, max per node with peak usage
|
||||||
|
|
||||||
|
**Last checked** timestamp
|
||||||
|
|
||||||
|
**Show Nodes / Hide Nodes** toggle button (only if nodes exist)
|
||||||
|
|
||||||
|
### NodeMetricCard (Expandable Nodes)
|
||||||
|
|
||||||
|
When nodes are expanded, each node shows:
|
||||||
|
|
||||||
|
- Node name with status icon (Ready green / NotReady red)
|
||||||
|
- Status badge (Ready / NotReady) and role badge (Control Plane / Worker)
|
||||||
|
- Age
|
||||||
|
- **CPU** — usage/allocatable with progress bar
|
||||||
|
- **Memory** — usage/allocatable with progress bar
|
||||||
|
- **GPU** — usage/capacity with progress bar (shows "No GPU" if none)
|
||||||
|
- Additional info: Pod count, Kubelet version
|
||||||
|
|
||||||
|
### States
|
||||||
|
|
||||||
|
- **Loading:** Shows "Loading cluster monitoring data..."
|
||||||
|
- **Error:** Error state with retry button, "Failed to Load Clusters"
|
||||||
|
- **Empty:** "No Clusters Available" with suggestion to add clusters in configuration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Setup — Clusters
|
||||||
|
|
||||||
|
The Cluster Configuration page at `/configuration/clusters` manages Kubernetes cluster connections.
|
||||||
|
|
||||||
|
### Page Header
|
||||||
|
|
||||||
|
- Title: "Configuration - Clusters"
|
||||||
|
- Description changes based on role (admin sees "Manage all..." , regular user sees "Manage your private...")
|
||||||
|
- **Refresh** button (secondary)
|
||||||
|
- **Add Cluster** button (primary, blue, plus icon)
|
||||||
|
|
||||||
|
### ClusterList Component
|
||||||
|
|
||||||
|
**Loading state:** Spinner with "Loading clusters..."
|
||||||
|
**Empty state:** Server icon with "No clusters" and "Add your first cluster..."
|
||||||
|
|
||||||
|
**Cluster cards** (2-column grid):
|
||||||
|
- Cluster name with server icon and visibility label (Private / Workspace / Global)
|
||||||
|
- Description (if any)
|
||||||
|
- Three action buttons:
|
||||||
|
- **Test Connection** (Activity icon, emerald) — performs a health check against the cluster
|
||||||
|
- **Edit** (pencil icon, blue) — opens edit modal
|
||||||
|
- **Delete** (trash icon, red) — prompts confirmation then deletes
|
||||||
|
- API Server URL (monospace, truncated)
|
||||||
|
- Auth status grid (3 columns): CA Certificate, Client Cert, Client Key — each shows "✅ Configured" or "✗ Not Configured"
|
||||||
|
- Created date
|
||||||
|
|
||||||
|
Action buttons may be disabled based on user permissions (read-only access).
|
||||||
|
|
||||||
|
### Add / Edit Cluster Modal
|
||||||
|
|
||||||
|
**Form fields:**
|
||||||
|
|
||||||
|
1. **Cluster Name** (required) — text input, e.g., "Production Cluster"
|
||||||
|
2. **API Server URL** (required) — must start with `https://`, e.g., `https://kubernetes.example.com:6443`
|
||||||
|
3. **CA Certificate (Base64)** — required for create. Textarea for base64-encoded CA cert. In edit mode: shows current status and optional new input.
|
||||||
|
4. **Client Certificate (Base64)** — required for create. In edit mode: shows current status.
|
||||||
|
5. **Client Key (Base64)** — required for create. In edit mode: shows current status.
|
||||||
|
6. **Bearer Token** — optional alternative to client certificates. Textarea for service account token.
|
||||||
|
7. **Description** — optional textarea
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- Name and API Server URL required
|
||||||
|
- URL must start with `http://` or `https://`
|
||||||
|
- Create mode requires either token OR all three certificate fields
|
||||||
|
- Edit mode: certificate fields are optional (leave blank to keep existing)
|
||||||
|
|
||||||
|
**Footer:** Cancel / Add Cluster or Save
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Clicking the **Test Connection** (Activity) button on a cluster:
|
||||||
|
- Shows "Testing cluster..." toast
|
||||||
|
- If successful: green toast with success message
|
||||||
|
- If failed: red toast with error message
|
||||||
|
- Checks connectivity to the Kubernetes API server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Setup — Registries
|
||||||
|
|
||||||
|
The Registry Configuration page at `/configuration/registries` manages OCI registry connections.
|
||||||
|
|
||||||
|
### Page Header
|
||||||
|
|
||||||
|
- Title: "Configuration - Registries"
|
||||||
|
- **Refresh** button (secondary)
|
||||||
|
- **Add Registry** button (primary, blue, plus icon)
|
||||||
|
|
||||||
|
### RegistryList Component
|
||||||
|
|
||||||
|
**Loading state:** "Loading registries..."
|
||||||
|
**Empty state:** Database icon with "No registries" and "Add your first registry..."
|
||||||
|
|
||||||
|
**Registry cards** (vertical list):
|
||||||
|
- Registry name with database icon and visibility label
|
||||||
|
- **Insecure** badge (yellow, if insecure flag is on)
|
||||||
|
- Registry URL (clickable link, opens in new tab)
|
||||||
|
- Description (if any)
|
||||||
|
- Username display
|
||||||
|
- Two action buttons:
|
||||||
|
- **Edit** (pencil icon, blue) — opens edit modal
|
||||||
|
- **Delete** (trash icon, red) — prompts confirmation then deletes
|
||||||
|
|
||||||
|
### Add / Edit Registry Modal
|
||||||
|
|
||||||
|
**Form fields:**
|
||||||
|
|
||||||
|
1. **Name** (required) — e.g., "Harbor Production"
|
||||||
|
2. **Registry URL** (required) — e.g., `https://registry.example.com`
|
||||||
|
3. **Username** (required) — registry username (Harbor robot account recommended)
|
||||||
|
4. **Password** — required for create. In edit mode: shows current status ("Password set - encrypted") and optional new password input
|
||||||
|
5. **Description** — optional textarea
|
||||||
|
6. **Insecure** — checkbox. "Allow insecure connection (skip SSL certificate verification)" — for registries using HTTP or self-signed certs
|
||||||
|
|
||||||
|
**Test Connection button** (in edit mode only, after saving):
|
||||||
|
- Tests the registry connectivity by calling the backend health endpoint
|
||||||
|
- Button shows a pulsing test tube icon while testing
|
||||||
|
- Shows success/failure toast
|
||||||
|
|
||||||
|
**Footer:** Save / Test Connection / Cancel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Setup — Users (Admin)
|
||||||
|
|
||||||
|
The User Management page at `/configuration/users` is **admin-only**. Non-admin users cannot access this route.
|
||||||
|
|
||||||
|
### Page Header
|
||||||
|
|
||||||
|
- "Admin only" label with shield icon
|
||||||
|
- Title: "User Management"
|
||||||
|
- Description: "Create accounts, assign roles, and disable access without public self-registration."
|
||||||
|
- **Refresh** button (secondary)
|
||||||
|
|
||||||
|
### Create User Form (Left Panel)
|
||||||
|
|
||||||
|
**Username** (required) — text input
|
||||||
|
**Initial password** (required) — masked input
|
||||||
|
**Role** dropdown — "User" or "Admin"
|
||||||
|
|
||||||
|
When **User** role is selected, additional fields appear:
|
||||||
|
|
||||||
|
**Tenant namespace section:**
|
||||||
|
- **Namespace** — text input, auto-generated from username as `ocdp-u-{username}`
|
||||||
|
- **Default cluster** — dropdown of available clusters
|
||||||
|
|
||||||
|
**Resource limits section:**
|
||||||
|
- **CPU** — default "4" (Kubernetes quantity, e.g., "4" or "500m")
|
||||||
|
- **Memory** — default "16Gi"
|
||||||
|
- **GPU** — default "0" (integer count)
|
||||||
|
- **GPU Mem** — default "0" (integer MB, e.g., 10000)
|
||||||
|
- Help text explains the units
|
||||||
|
|
||||||
|
**Checkbox:**
|
||||||
|
- "Require password change after first login" — checked by default
|
||||||
|
|
||||||
|
**Create User button** (primary, full-width, user-plus icon)
|
||||||
|
|
||||||
|
### Accounts Table (Right Panel)
|
||||||
|
|
||||||
|
A table with columns:
|
||||||
|
|
||||||
|
| Column | Content |
|
||||||
|
|-----------|------------------------------------------------------|
|
||||||
|
| User | Username + email |
|
||||||
|
| Role | Badge: "admin" (info blue) or "user" (secondary) |
|
||||||
|
| Status | Badge: "Active" (green) or "Disabled" (warning) |
|
||||||
|
| Namespace | Namespace + workspace name + default cluster |
|
||||||
|
| Quota | CPU, Memory, GPU/GPU Mem (admin shows "default workspace") |
|
||||||
|
| Actions | See below |
|
||||||
|
|
||||||
|
**Actions per row (4 buttons):**
|
||||||
|
|
||||||
|
1. **Make User / Make Admin** — toggles the user's role between admin and user
|
||||||
|
2. **Limits** (pencil icon) — opens the Edit Limits modal (only for non-admin users)
|
||||||
|
3. **Enable / Disable** — toggles the user's active status (disabled for own account)
|
||||||
|
4. **Delete** (trash icon, red) — deletes user after confirmation (disabled for own account)
|
||||||
|
|
||||||
|
### Edit Limits Modal
|
||||||
|
|
||||||
|
Opens when "Limits" button is clicked for a non-admin user:
|
||||||
|
|
||||||
|
- **Tenant limits** label with gauge icon
|
||||||
|
- User's name as title
|
||||||
|
- Description: "Changes are applied to workspace metadata..."
|
||||||
|
- Fields: Namespace, Default cluster, CPU, Memory, GPU, GPU Memory
|
||||||
|
- **Cancel** / **Save Limits** buttons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Navigation
|
||||||
|
|
||||||
|
### Left Sidebar
|
||||||
|
|
||||||
|
The sidebar shows the "Operations" branding at the top with the following navigation items:
|
||||||
|
|
||||||
|
| Item | Icon | Route |
|
||||||
|
|-------------------|-----------------------|----------------------------------|
|
||||||
|
| Home | Home (gray) | `/home` |
|
||||||
|
| Launch Instance | Rocket (blue) | `/artifact/registries` |
|
||||||
|
| Instances | Boxes (emerald) | `/artifact/instances` |
|
||||||
|
| Cluster Monitoring| LineChart (teal) | `/monitoring/clusters` |
|
||||||
|
| **Setup** (collapsible) | Settings | |
|
||||||
|
| └ Clusters | Server (teal) | `/configuration/clusters` |
|
||||||
|
| └ Registries | Database | `/configuration/registries` |
|
||||||
|
| └ Users | Users (blue) | `/configuration/users` |
|
||||||
|
|
||||||
|
- The Setup section is expanded by default
|
||||||
|
- Active nav item is highlighted with a blue background
|
||||||
|
- Sidebar collapses on mobile with a hamburger menu toggle
|
||||||
|
- Navigator items dynamically filter based on user role:
|
||||||
|
- "Users" is only shown to admin users
|
||||||
|
- Routes are protected server-side too
|
||||||
|
|
||||||
|
### Page Header / Breadcrumbs
|
||||||
|
|
||||||
|
Each page shows a header in the top navigation bar with:
|
||||||
|
- Page icon
|
||||||
|
- Page title (e.g., "Launch Instance", "Instances", "Setup - Clusters")
|
||||||
|
- Current user's name and role badge on the right
|
||||||
|
- **Sign Out** button (door icon with arrow, top-right)
|
||||||
|
|
||||||
|
The title mapping is:
|
||||||
|
|
||||||
|
| Route | Header Title |
|
||||||
|
|-------------------------------|-------------------------|
|
||||||
|
| `/artifact/registries` | Launch Instance |
|
||||||
|
| `/artifact/instances` | Instances |
|
||||||
|
| `/configuration/clusters` | Setup - Clusters |
|
||||||
|
| `/configuration/registries` | Setup - Registries |
|
||||||
|
| `/configuration/users` | Setup - Users |
|
||||||
|
| `/monitoring/clusters` | Monitoring - Clusters |
|
||||||
|
| `/home` | OCDP Platform |
|
||||||
|
|
||||||
|
### Legacy Route Redirects
|
||||||
|
|
||||||
|
Several legacy URL patterns redirect to current routes:
|
||||||
|
- `/config/*` → `/configuration/clusters`
|
||||||
|
- `/monitor`, `/cluster`, `/cluster/monitor` → `/monitoring/clusters`
|
||||||
|
- `/artifact/registry` → `/artifact/registries`
|
||||||
|
- `/artifact/instance` → `/artifact/instances`
|
||||||
|
- `/registry` → `/artifact/registries`
|
||||||
|
- `/register` → `/`
|
||||||
|
|
||||||
|
---
|
||||||
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@ -14,7 +14,8 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"reflect-metadata": "^0.2.2"
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
@ -8688,16 +8689,18 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz",
|
||||||
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
|
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14.6"
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yargs": {
|
"node_modules/yargs": {
|
||||||
|
|||||||
@ -18,7 +18,8 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"reflect-metadata": "^0.2.2"
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"yaml": "^2.8.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.36.0",
|
"@eslint/js": "^9.36.0",
|
||||||
|
|||||||
@ -28,9 +28,11 @@ const isTransformablePayload = (payload: unknown) => {
|
|||||||
return typeof payload === "object";
|
return typeof payload === "object";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SKIP_RECURSE_KEYS = new Set(["values", "valuesYaml"]);
|
||||||
|
|
||||||
AXIOS_INSTANCE.interceptors.request.use((config) => {
|
AXIOS_INSTANCE.interceptors.request.use((config) => {
|
||||||
if (isTransformablePayload(config.data)) {
|
if (isTransformablePayload(config.data)) {
|
||||||
config.data = keysToSnake(config.data);
|
config.data = keysToSnake(config.data, SKIP_RECURSE_KEYS);
|
||||||
}
|
}
|
||||||
if (isTransformablePayload(config.params)) {
|
if (isTransformablePayload(config.params)) {
|
||||||
config.params = keysToSnake(config.params);
|
config.params = keysToSnake(config.params);
|
||||||
|
|||||||
@ -271,6 +271,7 @@ export interface GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceR
|
|||||||
name?: string;
|
name?: string;
|
||||||
namespace?: string;
|
namespace?: string;
|
||||||
registryId?: string;
|
registryId?: string;
|
||||||
|
replicas?: number;
|
||||||
repository?: string;
|
repository?: string;
|
||||||
revision?: number;
|
revision?: number;
|
||||||
/** 实例当前状态 */
|
/** 实例当前状态 */
|
||||||
|
|||||||
@ -76,6 +76,8 @@ import type {
|
|||||||
PutRegistriesRegistryIdPathParameters,
|
PutRegistriesRegistryIdPathParameters,
|
||||||
} from './generated-orval/api.schemas';
|
} from './generated-orval/api.schemas';
|
||||||
|
|
||||||
|
import { AXIOS_INSTANCE, customAxiosInstance } from './axios-mutator';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation as GeneratedInstanceLastOperationEnum,
|
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseLastOperation as GeneratedInstanceLastOperationEnum,
|
||||||
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus as GeneratedInstanceStatusEnum,
|
GithubComOcdpClusterServiceInternalAdapterInputHttpDtoInstanceResponseStatus as GeneratedInstanceStatusEnum,
|
||||||
@ -91,9 +93,46 @@ export type * from './generated-orval/api.schemas';
|
|||||||
// ---------- Friendly type aliases ----------
|
// ---------- Friendly type aliases ----------
|
||||||
export type AuthResponse = GeneratedAuthResponse;
|
export type AuthResponse = GeneratedAuthResponse;
|
||||||
export type RegisterBody = GeneratedRegisterRequest;
|
export type RegisterBody = GeneratedRegisterRequest;
|
||||||
|
export type AdminCreateUserRequest = RegisterBody & {
|
||||||
|
role?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
defaultClusterId?: string;
|
||||||
|
quotaCpu?: string;
|
||||||
|
quotaMemory?: string;
|
||||||
|
quotaGpu?: string;
|
||||||
|
quotaGpuMemory?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
};
|
||||||
export type LoginBody = GeneratedLoginRequest;
|
export type LoginBody = GeneratedLoginRequest;
|
||||||
export type RefreshTokenBody = GeneratedRefreshTokenRequest;
|
export type RefreshTokenBody = GeneratedRefreshTokenRequest;
|
||||||
export type UserResponse = GeneratedUserResponse;
|
export type UserResponse = GeneratedUserResponse & {
|
||||||
|
role?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
workspaceName?: string;
|
||||||
|
namespace?: string;
|
||||||
|
defaultClusterId?: string;
|
||||||
|
quotaCpu?: string;
|
||||||
|
quotaMemory?: string;
|
||||||
|
quotaGpu?: string;
|
||||||
|
quotaGpuMemory?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
};
|
||||||
|
export type UpdateUserRequest = {
|
||||||
|
role?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
defaultClusterId?: string;
|
||||||
|
quotaCpu?: string;
|
||||||
|
quotaMemory?: string;
|
||||||
|
quotaGpu?: string;
|
||||||
|
quotaGpuMemory?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
};
|
||||||
|
export type ValuesYamlResponse = { valuesYaml: string };
|
||||||
|
|
||||||
export type ClusterResponse = GeneratedClusterResponse;
|
export type ClusterResponse = GeneratedClusterResponse;
|
||||||
export type CreateClusterRequest = GeneratedCreateClusterRequest;
|
export type CreateClusterRequest = GeneratedCreateClusterRequest;
|
||||||
@ -108,6 +147,49 @@ export type InstanceResponse = GeneratedInstanceResponse;
|
|||||||
export type CreateInstanceRequest = GeneratedCreateInstanceRequest;
|
export type CreateInstanceRequest = GeneratedCreateInstanceRequest;
|
||||||
export type UpdateInstanceRequest = GeneratedUpdateInstanceRequest;
|
export type UpdateInstanceRequest = GeneratedUpdateInstanceRequest;
|
||||||
export type InstanceEntry = GeneratedInstanceEntry;
|
export type InstanceEntry = GeneratedInstanceEntry;
|
||||||
|
export type InstanceDiagnosticsResponse = {
|
||||||
|
instanceName?: string;
|
||||||
|
namespace?: string;
|
||||||
|
collectedAt?: string;
|
||||||
|
pods?: Array<{
|
||||||
|
name?: string;
|
||||||
|
namespace?: string;
|
||||||
|
phase?: string;
|
||||||
|
nodeName?: string;
|
||||||
|
podIp?: string;
|
||||||
|
hostIp?: string;
|
||||||
|
restartCount?: number;
|
||||||
|
containers?: Array<{
|
||||||
|
name?: string;
|
||||||
|
image?: string;
|
||||||
|
ready?: boolean;
|
||||||
|
restartCount?: number;
|
||||||
|
state?: string;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
conditions?: Array<{ type?: string; status?: string; reason?: string; message?: string }>;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
}>;
|
||||||
|
services?: Array<{
|
||||||
|
name?: string;
|
||||||
|
namespace?: string;
|
||||||
|
type?: string;
|
||||||
|
clusterIP?: string;
|
||||||
|
ports?: Array<{ name?: string; protocol?: string; port?: number; targetPort?: string; nodePort?: number }>;
|
||||||
|
}>;
|
||||||
|
events?: Array<{
|
||||||
|
type?: string;
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
involvedKind?: string;
|
||||||
|
involvedName?: string;
|
||||||
|
count?: number;
|
||||||
|
firstTimestamp?: string;
|
||||||
|
lastTimestamp?: string;
|
||||||
|
}>;
|
||||||
|
logs?: Array<{ pod?: string; container?: string; tailLines?: number; log?: string; error?: string }>;
|
||||||
|
};
|
||||||
export const INSTANCE_STATUS = GeneratedInstanceStatusEnum;
|
export const INSTANCE_STATUS = GeneratedInstanceStatusEnum;
|
||||||
export type InstanceStatus = NonNullable<InstanceResponse['status']>;
|
export type InstanceStatus = NonNullable<InstanceResponse['status']>;
|
||||||
export const INSTANCE_LAST_OPERATION = GeneratedInstanceLastOperationEnum;
|
export const INSTANCE_LAST_OPERATION = GeneratedInstanceLastOperationEnum;
|
||||||
@ -134,6 +216,13 @@ export type NodeMetricsResponse = GeneratedNodeMetricsResponse;
|
|||||||
export const login = postAuthLogin;
|
export const login = postAuthLogin;
|
||||||
export const register = postAuthRegister;
|
export const register = postAuthRegister;
|
||||||
export const refreshAuth = postAuthRefresh;
|
export const refreshAuth = postAuthRefresh;
|
||||||
|
export const listUsers = () => customAxiosInstance<UserResponse[]>({ url: "/users", method: "GET" });
|
||||||
|
export const createUser = (data: AdminCreateUserRequest) =>
|
||||||
|
customAxiosInstance<UserResponse>({ url: "/users", method: "POST", data });
|
||||||
|
export const updateUser = (userId: string, data: UpdateUserRequest) =>
|
||||||
|
customAxiosInstance<UserResponse>({ url: `/users/${encodeURIComponent(userId)}`, method: "PUT", data });
|
||||||
|
export const deleteUser = (userId: string) =>
|
||||||
|
customAxiosInstance<void>({ url: `/users/${encodeURIComponent(userId)}`, method: "DELETE" });
|
||||||
|
|
||||||
export const listClusters = getClusters;
|
export const listClusters = getClusters;
|
||||||
export const createCluster = postClusters;
|
export const createCluster = postClusters;
|
||||||
@ -148,6 +237,117 @@ export const getInstance = getClustersClusterIdInstancesInstanceId;
|
|||||||
export const updateInstance = putClustersClusterIdInstancesInstanceId;
|
export const updateInstance = putClustersClusterIdInstancesInstanceId;
|
||||||
export const deleteInstance = deleteClustersClusterIdInstancesInstanceId;
|
export const deleteInstance = deleteClustersClusterIdInstancesInstanceId;
|
||||||
export const listInstanceEntries = getClustersClusterIdInstancesInstanceIdEntries;
|
export const listInstanceEntries = getClustersClusterIdInstancesInstanceIdEntries;
|
||||||
|
export const scaleInstance = (
|
||||||
|
clusterId: string,
|
||||||
|
instanceId: string,
|
||||||
|
body: { replicas: number; workload?: string },
|
||||||
|
) => {
|
||||||
|
return customAxiosInstance<{ instance: any; replicas: number; message: string }>({
|
||||||
|
url: `/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/scale`,
|
||||||
|
method: "POST",
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const getInstanceValuesDiff = (
|
||||||
|
clusterId: string,
|
||||||
|
instanceId: string,
|
||||||
|
) => {
|
||||||
|
return customAxiosInstance<{ current: Record<string, any>; defaults: Record<string, any> }>({
|
||||||
|
url: `/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/values-diff`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
export const getInstanceDiagnostics = (
|
||||||
|
params: { clusterId: string; instanceId: string },
|
||||||
|
options?: { tailLines?: number },
|
||||||
|
) =>
|
||||||
|
customAxiosInstance<InstanceDiagnosticsResponse>({
|
||||||
|
url: `/clusters/${encodeURIComponent(params.clusterId)}/instances/${encodeURIComponent(params.instanceId)}/diagnostics`,
|
||||||
|
method: "GET",
|
||||||
|
params: options?.tailLines ? { tailLines: options.tailLines } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream pod logs via SSE from the backend.
|
||||||
|
* Returns an AbortController to cancel the stream at any time.
|
||||||
|
*/
|
||||||
|
export function streamInstanceLogs(
|
||||||
|
clusterId: string,
|
||||||
|
instanceId: string,
|
||||||
|
pod: string,
|
||||||
|
container: string,
|
||||||
|
tailLines: number = 200,
|
||||||
|
onLine: (line: string) => void,
|
||||||
|
onDone: () => void,
|
||||||
|
onError: (err: Error) => void,
|
||||||
|
): AbortController {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const baseUrl = AXIOS_INSTANCE.defaults.baseURL ?? "/api/v1";
|
||||||
|
const authHeader = AXIOS_INSTANCE.defaults.headers.common["Authorization"] as string | undefined;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ pod, container, tailLines: String(tailLines) });
|
||||||
|
const url = `${baseUrl}/clusters/${encodeURIComponent(clusterId)}/instances/${encodeURIComponent(instanceId)}/logs/stream?${params}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { Accept: "text/event-stream" };
|
||||||
|
if (authHeader) {
|
||||||
|
headers["Authorization"] = authHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url, { headers, signal: controller.signal })
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => response.statusText);
|
||||||
|
onError(new Error(`HTTP ${response.status}: ${text}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
onError(new Error("ReadableStream not supported"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
// Keep the last potentially-incomplete line in the buffer
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
||||||
|
const data = trimmed.slice(5).trim();
|
||||||
|
if (data === "[DONE]") {
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.startsWith("[ERROR]")) {
|
||||||
|
onError(new Error(data.slice(7).trim()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
onLine(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
// Stream was intentionally cancelled - not an error
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onError(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
});
|
||||||
|
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
export const listRegistries = getRegistries;
|
export const listRegistries = getRegistries;
|
||||||
export const createRegistry = postRegistries;
|
export const createRegistry = postRegistries;
|
||||||
@ -156,7 +356,13 @@ export const updateRegistry = putRegistriesRegistryId;
|
|||||||
export const deleteRegistry = deleteRegistriesRegistryId;
|
export const deleteRegistry = deleteRegistriesRegistryId;
|
||||||
export const checkRegistryHealth = getRegistriesRegistryIdHealth;
|
export const checkRegistryHealth = getRegistriesRegistryIdHealth;
|
||||||
|
|
||||||
export const listRepositories = getRegistriesRegistryIdRepositories;
|
export const listRepositories = (
|
||||||
|
params: GetRegistriesRegistryIdRepositoriesPathParameters,
|
||||||
|
options?: { artifactType?: 'chart' | 'all' },
|
||||||
|
) =>
|
||||||
|
getRegistriesRegistryIdRepositories(params, {
|
||||||
|
params: options?.artifactType ? { artifact_type: options.artifactType } : undefined,
|
||||||
|
});
|
||||||
type ListArtifactsRequestOptions = AxiosOptions<typeof getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts>;
|
type ListArtifactsRequestOptions = AxiosOptions<typeof getRegistriesRegistryIdRepositoriesRepositoryNameArtifacts>;
|
||||||
|
|
||||||
export const listArtifacts = (
|
export const listArtifacts = (
|
||||||
@ -173,6 +379,11 @@ export const listArtifacts = (
|
|||||||
|
|
||||||
export const getArtifact = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReference;
|
export const getArtifact = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReference;
|
||||||
export const getValuesSchema = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchema;
|
export const getValuesSchema = getRegistriesRegistryIdRepositoriesRepositoryNameArtifactsReferenceValuesSchema;
|
||||||
|
export const getValuesYaml = (params: GetValuesSchemaPathParameters) =>
|
||||||
|
customAxiosInstance<ValuesYamlResponse>({
|
||||||
|
url: `/registries/${encodeURIComponent(params.registryId)}/repositories/${encodeURIComponent(params.repositoryName)}/artifacts/${encodeURIComponent(params.reference)}/values-yaml`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
export const listClusterMonitoring = getMonitoringClusters;
|
export const listClusterMonitoring = getMonitoringClusters;
|
||||||
export const getClusterMonitoring = getMonitoringClustersClusterId;
|
export const getClusterMonitoring = getMonitoringClustersClusterId;
|
||||||
|
|||||||
@ -16,18 +16,22 @@ import { getNavItems } from "./constants/navigation";
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, login, logout } = useAuth();
|
const { isAuthenticated, login, logout, user } = useAuth();
|
||||||
|
|
||||||
// Generate navigation items based on current location
|
// Generate navigation items based on current location
|
||||||
const navItems = useMemo(
|
const navItems = useMemo(
|
||||||
() => getNavItems(location.pathname, navigate),
|
() => getNavItems(location.pathname, navigate, user),
|
||||||
[location.pathname, navigate]
|
[location.pathname, navigate, user]
|
||||||
);
|
);
|
||||||
|
const displayName = user?.workspaceName
|
||||||
|
? `${user.username || "User"} · ${user.workspaceName}`
|
||||||
|
: user?.username || "User";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppRoutes
|
<AppRoutes
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={isAuthenticated}
|
||||||
userName="User"
|
userName={displayName}
|
||||||
|
user={user}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
onLogin={login}
|
onLogin={login}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
@ -35,4 +39,3 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user