- 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
184 lines
6.5 KiB
Go
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())
|
|
}
|
|
}
|