Files
ocdp-go/backend/internal/adapter/input/http/rest/workspace_handler.go
Ivan087 33ddaf97db 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
2026-05-20 16:56:29 +08:00

184 lines
6.5 KiB
Go

package rest
import (
"encoding/json"
"errors"
"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")
}
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")
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) {
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:
respondError(w, http.StatusBadRequest, fallback, err.Error())
}
}