fix: scale replicas in response, K8s metrics client, quota precheck, auth tests
- Add GetMetrics method to MetricsClient interface and implement cluster metrics API - Add QuotaPrecheck service for validating resource quotas before deployment - Add auth DTO with role/permission models and auth handler tests - Add instance diagnostics: mounted NFS volumes, labels, annotations in pod diagnostics - Update workspace handler with GetWorkspace endpoint and shared-user list - Fix monitoring handler to use correct service method name - Add tail_lines fallback in instance handler for snake_case query params - Update nginx config for SSE log streaming support (no buffering) - Add comprehensive test coverage: auth_service_test, auth_handler_test, auth_dto_test, metrics_client_test, quota_precheck_test - Update error messages for quota validation and instance operations - ModifyModal: fix YAML lineWidth:0, modified keys summary, delta-only submit - InstanceCard: correctly disable scale-minus when replicas <= 0 - SidebarLayout: add hover transition for sidebar items - Update todo.md and lessons.md with latest fixes
This commit is contained in:
@ -126,6 +126,25 @@ func (h *ArtifactHandler) ListArtifacts(w http.ResponseWriter, r *http.Request)
|
||||
respondJSON(w, http.StatusOK, tagResponses)
|
||||
}
|
||||
|
||||
// ListRepositoryTags is a compatibility alias for clients that request tags
|
||||
// directly instead of the canonical artifacts endpoint.
|
||||
func (h *ArtifactHandler) ListRepositoryTags(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
if vars["registry_id"] == "" {
|
||||
registryID := r.URL.Query().Get("registry_id")
|
||||
if registryID == "" {
|
||||
registryID = r.URL.Query().Get("registryId")
|
||||
}
|
||||
if registryID == "" {
|
||||
respondError(w, http.StatusBadRequest, "Missing registry ID", "registry_id query parameter is required")
|
||||
return
|
||||
}
|
||||
vars["registry_id"] = registryID
|
||||
r = mux.SetURLVars(r, vars)
|
||||
}
|
||||
h.ListArtifacts(w, r)
|
||||
}
|
||||
|
||||
// GetArtifact 获取 artifact 详情
|
||||
// @Summary 获取 Artifact 详情
|
||||
// @Description 获取指定 Artifact 的详细信息
|
||||
|
||||
@ -3,8 +3,11 @@ package rest
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
@ -18,6 +21,74 @@ type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
const (
|
||||
loginRateLimitWindow = time.Minute
|
||||
loginRateLimitFailures = 5
|
||||
)
|
||||
|
||||
var defaultLoginRateLimiter = newLoginRateLimiter(loginRateLimitWindow, loginRateLimitFailures)
|
||||
|
||||
type loginRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
window time.Duration
|
||||
limit int
|
||||
failures map[string]loginFailureState
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type loginFailureState struct {
|
||||
count int
|
||||
windowEnds time.Time
|
||||
}
|
||||
|
||||
func newLoginRateLimiter(window time.Duration, limit int) *loginRateLimiter {
|
||||
return &loginRateLimiter{
|
||||
window: window,
|
||||
limit: limit,
|
||||
failures: make(map[string]loginFailureState),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *loginRateLimiter) Allow(key string) bool {
|
||||
if l == nil || key == "" {
|
||||
return true
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
state, ok := l.failures[key]
|
||||
now := l.now()
|
||||
if !ok || now.After(state.windowEnds) {
|
||||
return true
|
||||
}
|
||||
return state.count < l.limit
|
||||
}
|
||||
|
||||
func (l *loginRateLimiter) RecordFailure(key string) {
|
||||
if l == nil || key == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := l.now()
|
||||
state, ok := l.failures[key]
|
||||
if !ok || now.After(state.windowEnds) {
|
||||
l.failures[key] = loginFailureState{count: 1, windowEnds: now.Add(l.window)}
|
||||
return
|
||||
}
|
||||
state.count++
|
||||
l.failures[key] = state
|
||||
}
|
||||
|
||||
func (l *loginRateLimiter) Reset(key string) {
|
||||
if l == nil || key == "" {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.failures, key)
|
||||
}
|
||||
|
||||
// NewAuthHandler 创建认证 Handler
|
||||
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
@ -41,6 +112,7 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
|
||||
// 调用领域服务
|
||||
user, err := h.authService.Register(r.Context(), req.Username, req.Password, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
|
||||
@ -79,6 +151,7 @@ func (h *AuthHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
user, err := h.authService.UpdateUser(r.Context(), userID, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
|
||||
Namespace: req.Namespace,
|
||||
DefaultClusterID: req.DefaultClusterID,
|
||||
@ -120,12 +193,21 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
rateLimitKey := loginRateLimitKey(r, req.Username)
|
||||
if !defaultLoginRateLimiter.Allow(rateLimitKey) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
respondError(w, http.StatusTooManyRequests, "Too many login attempts", "too many login attempts; retry later")
|
||||
return
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
accessToken, refreshToken, user, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
|
||||
defaultLoginRateLimiter.RecordFailure(rateLimitKey)
|
||||
respondError(w, http.StatusUnauthorized, "Invalid username or password", "invalid username or password")
|
||||
return
|
||||
}
|
||||
defaultLoginRateLimiter.Reset(rateLimitKey)
|
||||
|
||||
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
|
||||
|
||||
@ -151,6 +233,23 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func loginRateLimitKey(r *http.Request, username string) string {
|
||||
client := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
|
||||
if idx := strings.Index(client, ","); idx >= 0 {
|
||||
client = strings.TrimSpace(client[:idx])
|
||||
}
|
||||
if client == "" {
|
||||
client = strings.TrimSpace(r.Header.Get("X-Real-IP"))
|
||||
}
|
||||
if client == "" {
|
||||
client = r.RemoteAddr
|
||||
if host, _, err := net.SplitHostPort(client); err == nil {
|
||||
client = host
|
||||
}
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(username)) + "|" + client
|
||||
}
|
||||
|
||||
func (h *AuthHandler) convertUserResponse(ctx context.Context, user *entity.User) *dto.UserResponse {
|
||||
workspace, _ := h.authService.GetWorkspaceByID(ctx, user.WorkspaceID)
|
||||
return &dto.UserResponse{
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginRateLimiterBlocksAfterConfiguredFailures(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 12, 0, 0, 0, time.UTC)
|
||||
limiter := newLoginRateLimiter(time.Minute, 2)
|
||||
limiter.now = func() time.Time { return now }
|
||||
|
||||
key := "user|127.0.0.1"
|
||||
if !limiter.Allow(key) {
|
||||
t.Fatal("expected first attempt to be allowed")
|
||||
}
|
||||
limiter.RecordFailure(key)
|
||||
if !limiter.Allow(key) {
|
||||
t.Fatal("expected second attempt to be allowed")
|
||||
}
|
||||
limiter.RecordFailure(key)
|
||||
if limiter.Allow(key) {
|
||||
t.Fatal("expected third attempt inside the window to be blocked")
|
||||
}
|
||||
|
||||
now = now.Add(time.Minute + time.Second)
|
||||
if !limiter.Allow(key) {
|
||||
t.Fatal("expected attempts to be allowed after the window expires")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRateLimiterResetClearsFailures(t *testing.T) {
|
||||
limiter := newLoginRateLimiter(time.Minute, 1)
|
||||
key := "user|127.0.0.1"
|
||||
|
||||
limiter.RecordFailure(key)
|
||||
if limiter.Allow(key) {
|
||||
t.Fatal("expected key to be blocked after one failure")
|
||||
}
|
||||
limiter.Reset(key)
|
||||
if !limiter.Allow(key) {
|
||||
t.Fatal("expected reset key to be allowed")
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -49,6 +50,11 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
parsedYAML, hasValuesYAML, err := parseAndCompareValues(req.Values, req.ValuesYAML)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid values", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Extract chart name from repository (e.g., "charts/nginx" -> "nginx")
|
||||
chart := req.Repository
|
||||
@ -71,21 +77,16 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
|
||||
if req.Values != nil {
|
||||
instance.SetValues(req.Values)
|
||||
}
|
||||
if req.ValuesYAML != "" {
|
||||
if hasValuesYAML {
|
||||
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)
|
||||
instance.SetValues(parsedYAML)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.instanceService.CreateInstance(r.Context(), instance); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to create instance", err.Error())
|
||||
respondServiceError(w, err, "Failed to create instance")
|
||||
return
|
||||
}
|
||||
|
||||
@ -116,6 +117,7 @@ func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "Instance not found", "resource does not belong to cluster")
|
||||
return
|
||||
}
|
||||
h.instanceService.EnrichReplicas(r.Context(), clusterID, []*entity.Instance{instance})
|
||||
|
||||
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true))
|
||||
}
|
||||
@ -144,7 +146,7 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
responses := make([]*dto.InstanceResponse, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
responses = append(responses, convertInstanceResponse(instance, false))
|
||||
responses = append(responses, convertInstanceResponse(instance, true))
|
||||
}
|
||||
|
||||
response := &dto.InstanceListResponse{
|
||||
@ -177,6 +179,11 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
parsedYAML, hasValuesYAML, err := parseAndCompareValues(req.Values, req.ValuesYAML)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid values", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取现有实例
|
||||
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||
@ -194,21 +201,16 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
|
||||
if req.Description != "" {
|
||||
instance.Description = req.Description
|
||||
}
|
||||
if req.ValuesYAML != "" {
|
||||
if hasValuesYAML {
|
||||
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)
|
||||
instance.SetValues(parsedYAML)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.instanceService.UpdateInstance(r.Context(), instance); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to update instance", err.Error())
|
||||
respondServiceError(w, err, "Failed to update instance")
|
||||
return
|
||||
}
|
||||
|
||||
@ -345,7 +347,6 @@ func (h *InstanceHandler) StreamInstanceLogs(w http.ResponseWriter, r *http.Requ
|
||||
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 {
|
||||
@ -585,6 +586,7 @@ func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto
|
||||
Status: string(instance.Status),
|
||||
WorkspaceID: instance.WorkspaceID,
|
||||
OwnerID: instance.OwnerID,
|
||||
OwnerUsername: instance.OwnerUsername,
|
||||
StatusReason: instance.StatusReason,
|
||||
LastOperation: string(instance.LastOperation),
|
||||
LastError: instance.LastError,
|
||||
@ -622,6 +624,43 @@ func parseValuesYAML(valuesYAML string) (map[string]interface{}, error) {
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func parseAndCompareValues(values map[string]interface{}, valuesYAML string) (map[string]interface{}, bool, error) {
|
||||
if strings.TrimSpace(valuesYAML) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
parsed, err := parseValuesYAML(valuesYAML)
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("invalid values YAML: %w", err)
|
||||
}
|
||||
if values == nil {
|
||||
return parsed, true, nil
|
||||
}
|
||||
normalizedValues, err := normalizeJSONComparable(values)
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("invalid values: %w", err)
|
||||
}
|
||||
normalizedYAML, err := normalizeJSONComparable(parsed)
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("invalid values YAML: %w", err)
|
||||
}
|
||||
if !reflect.DeepEqual(normalizedValues, normalizedYAML) {
|
||||
return nil, true, fmt.Errorf("values and valuesYaml conflict")
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func normalizeJSONComparable(value interface{}) (interface{}, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var normalized interface{}
|
||||
if err := json.Unmarshal(data, &normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeYAMLValue(value interface{}) (interface{}, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
|
||||
@ -43,6 +43,12 @@ func (h *MonitoringHandler) GetClusterMonitoring(w http.ResponseWriter, r *http.
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetClusterStats is a compatibility alias for cluster detail dashboards that
|
||||
// historically read stats from /clusters/{id}/stats.
|
||||
func (h *MonitoringHandler) GetClusterStats(w http.ResponseWriter, r *http.Request) {
|
||||
h.GetClusterMonitoring(w, r)
|
||||
}
|
||||
|
||||
// ListClusterMonitoring 获取所有集群的监控信息
|
||||
// @Summary 列出集群监控
|
||||
// @Tags Monitoring
|
||||
|
||||
@ -2,6 +2,7 @@ package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -113,6 +114,15 @@ func (h *WorkspaceHandler) IssueCurrentKubeconfig(w http.ResponseWriter, r *http
|
||||
if clusterID == "" {
|
||||
clusterID = r.URL.Query().Get("cluster_id")
|
||||
}
|
||||
h.issueCurrentKubeconfigForCluster(w, r, clusterID)
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) IssueClusterKubeconfig(w http.ResponseWriter, r *http.Request) {
|
||||
clusterID := mux.Vars(r)["cluster_id"]
|
||||
h.issueCurrentKubeconfigForCluster(w, r, clusterID)
|
||||
}
|
||||
|
||||
func (h *WorkspaceHandler) issueCurrentKubeconfigForCluster(w http.ResponseWriter, r *http.Request, clusterID string) {
|
||||
kubeconfig, err := h.workspaceService.IssueCurrentKubeconfig(r.Context(), clusterID, 2*time.Hour)
|
||||
if err != nil {
|
||||
respondServiceError(w, err, "Failed to issue kubeconfig")
|
||||
@ -152,11 +162,19 @@ func toWorkspaceResponse(workspace *entity.Workspace) workspaceResponse {
|
||||
}
|
||||
|
||||
func respondServiceError(w http.ResponseWriter, err error, fallback string) {
|
||||
if errors.Is(err, service.ErrQuotaExceeded) {
|
||||
respondError(w, http.StatusUnprocessableEntity, "Quota exceeded", err.Error())
|
||||
return
|
||||
}
|
||||
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.ErrWorkspaceNamespaceConflict, entity.ErrUserHasInstances, entity.ErrWorkspaceExists, entity.ErrInstanceExists:
|
||||
respondError(w, http.StatusConflict, "Conflict", err.Error())
|
||||
case entity.ErrProtectedNamespace:
|
||||
respondError(w, http.StatusForbidden, "Forbidden", err.Error())
|
||||
case entity.ErrClusterNotFound, entity.ErrRegistryNotFound, entity.ErrInstanceNotFound, entity.ErrWorkspaceNotFound:
|
||||
respondError(w, http.StatusNotFound, fallback, err.Error())
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user