package handler import ( "bytes" "context" "fmt" "io" "k8s.io/apimachinery/pkg/api/errors" k8smeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/yaml" "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" "github.com/goodrain/rainbond/db" "github.com/goodrain/rainbond/pkg/component/k8s" httputil "github.com/goodrain/rainbond/util/http" corev1 "k8s.io/api/core/v1" ) // NsResourceInfo is the list item response for a namespace-scoped resource type NsResourceInfo struct { Name string `json:"name"` Kind string `json:"kind"` APIVersion string `json:"api_version"` Status string `json:"status"` Replicas int64 `json:"replicas,omitempty"` ReadyReplicas int64 `json:"ready_replicas,omitempty"` Source string `json:"source"` CreatedAt string `json:"created_at"` Node string `json:"node,omitempty"` RestartCount int32 `json:"restart_count,omitempty"` Owner string `json:"owner,omitempty"` PodIP string `json:"pod_ip,omitempty"` Type string `json:"type,omitempty"` ClusterIP string `json:"cluster_ip,omitempty"` Ports []corev1.ServicePort `json:"ports,omitempty"` Selector map[string]string `json:"selector,omitempty"` IngressClass string `json:"ingress_class,omitempty"` Hosts []string `json:"hosts,omitempty"` TLSHosts []string `json:"tls_hosts,omitempty"` BackendServices []string `json:"backend_services,omitempty"` DataCount int `json:"data_count,omitempty"` Storage string `json:"storage,omitempty"` AccessModes []string `json:"access_modes,omitempty"` StorageClass string `json:"storage_class,omitempty"` VolumeName string `json:"volume_name,omitempty"` } // NsResourceCreateSummary is the aggregated create result summary. type NsResourceCreateSummary struct { Total int `json:"total"` SuccessCount int `json:"success_count"` FailureCount int `json:"failure_count"` PartialSuccess bool `json:"partial_success"` } // NsResourceCreateResult is the per-document create result. type NsResourceCreateResult struct { Index int `json:"index"` APIVersion string `json:"api_version"` Kind string `json:"kind"` Name string `json:"name"` Namespace string `json:"namespace"` ResourceScope string `json:"resource_scope"` Success bool `json:"success"` Message string `json:"message"` } // NsResourceCreateResponse is the batch create response. type NsResourceCreateResponse struct { Message string `json:"message"` Summary NsResourceCreateSummary `json:"summary"` Results []NsResourceCreateResult `json:"results"` } // NsResourceHandler handles namespace-scoped K8s resource operations type NsResourceHandler struct{} var nsResourceRESTMapper = func() k8smeta.RESTMapper { if k8s.Default() == nil { return nil } return k8s.Default().Mapper } var nsResourceDynamicClient = func() dynamic.Interface { if k8s.Default() == nil { return nil } return k8s.Default().DynamicClient } // ListNsResourceTypes returns all namespace-scoped resource types from the cluster func (h *NsResourceHandler) ListNsResourceTypes() ([]ResourceTypeInfo, error) { dc := k8s.Default().Clientset.Discovery() _, resList, err := dc.ServerGroupsAndResources() if err != nil && !discovery.IsGroupDiscoveryFailedError(err) { return nil, err } var types []ResourceTypeInfo for _, rl := range resList { gv, parseErr := schema.ParseGroupVersion(rl.GroupVersion) if parseErr != nil { continue } for _, r := range rl.APIResources { if r.Namespaced && !containsSlash(r.Name) { types = append(types, ResourceTypeInfo{ Group: gv.Group, Version: gv.Version, Kind: r.Kind, Resource: r.Name, Verbs: r.Verbs, }) } } } return types, nil } // ListNsResources returns all resources of the given GVR in the tenant namespace func (h *NsResourceHandler) ListNsResources(tenantName, group, version, resource string) ([]NsResourceInfo, error) { if err := validateGVRParams(group, version, resource); err != nil { return nil, err } ns, err := h.getTenantNamespace(tenantName) if err != nil { return nil, err } gvr := schema.GroupVersionResource{Group: group, Version: version, Resource: resource} list, err := k8s.Default().DynamicClient.Resource(gvr).Namespace(ns).List(context.Background(), metav1.ListOptions{}) if err != nil { return nil, err } var result []NsResourceInfo for _, item := range list.Items { result = append(result, toNsResourceInfo(item)) } return result, nil } // GetNsResource returns a single resource by name from the tenant namespace func (h *NsResourceHandler) GetNsResource(tenantName, group, version, resource, name string) (*unstructured.Unstructured, error) { if err := validateGVRParams(group, version, resource); err != nil { return nil, err } ns, err := h.getTenantNamespace(tenantName) if err != nil { return nil, err } gvr := schema.GroupVersionResource{Group: group, Version: version, Resource: resource} return k8s.Default().DynamicClient.Resource(gvr).Namespace(ns).Get(context.Background(), name, metav1.GetOptions{}) } // CreateNsResource creates resources from YAML body, resolving the actual target GVR from each document. func (h *NsResourceHandler) CreateNsResource(tenantName, source string, yamlBody []byte) (out *NsResourceCreateResponse, statusCode int, resultErr error) { if source != "yaml" && source != "manual" { source = "manual" } ns, err := h.getTenantNamespace(tenantName) if err != nil { return nil, 0, err } documents, err := decodeNsResourceDocuments(yamlBody) if err != nil { return nil, 0, err } mapper := nsResourceRESTMapper() if mapper == nil { return nil, 0, fmt.Errorf("kubernetes rest mapper is not initialized") } dynamicClient := nsResourceDynamicClient() if dynamicClient == nil { return nil, 0, fmt.Errorf("kubernetes dynamic client is not initialized") } admission, err := admitWorkload(db.GetManager().DB(), yamlBody) if err != nil { return nil, 409, err } defer func() { if err := admission.finish(true); err != nil && resultErr == nil { resultErr = err statusCode = 503 } }() response := &NsResourceCreateResponse{ Results: make([]NsResourceCreateResult, 0, len(documents)), } for i, obj := range documents { result := NsResourceCreateResult{ Index: i + 1, APIVersion: obj.GetAPIVersion(), Kind: obj.GetKind(), Name: obj.GetName(), } h.createSingleNsResource(dynamicClient, mapper, ns, source, obj, &result, admission.observe) response.Results = append(response.Results, result) } response.Summary = buildNsResourceCreateSummary(response.Results) response.Message = buildNsResourceCreateMessage(response.Summary) return response, nsResourceCreateStatusCode(response.Summary), nil } // UpdateNsResource updates a resource in the tenant namespace from YAML body func (h *NsResourceHandler) UpdateNsResource(tenantName, group, version, resource, name string, yamlBody []byte) (out *unstructured.Unstructured, resultErr error) { if err := validateGVRParams(group, version, resource); err != nil { return nil, err } ns, err := h.getTenantNamespace(tenantName) if err != nil { return nil, err } obj := &unstructured.Unstructured{} decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(yamlBody), 4096) if err := decoder.Decode(obj); err != nil { return nil, httputil.NewErrBadRequest(fmt.Errorf("invalid YAML: %v", err)) } obj.SetNamespace(ns) if obj.GetName() == "" { obj.SetName(name) } dynamicClient := nsResourceDynamicClient() if dynamicClient == nil { return nil, fmt.Errorf("kubernetes dynamic client is not initialized") } gvr := schema.GroupVersionResource{Group: group, Version: version, Resource: resource} if obj.GetResourceVersion() == "" { current, err := dynamicClient.Resource(gvr).Namespace(ns).Get(context.Background(), name, metav1.GetOptions{}) if err != nil { return nil, err } obj.SetResourceVersion(current.GetResourceVersion()) } admission, err := admitWorkload(db.GetManager().DB(), yamlBody) if err != nil { return nil, err } defer func() { if err := admission.finish(true); err != nil && resultErr == nil { resultErr = err } }() admission.observe(true, nil) result, err := dynamicClient.Resource(gvr).Namespace(ns).Update(context.Background(), obj, metav1.UpdateOptions{}) admission.observe(false, err) if err != nil && (errors.IsInvalid(err) || errors.IsBadRequest(err)) { return nil, httputil.NewErrBadRequest(err) } return result, err } // DeleteNsResource deletes a resource by name from the tenant namespace func (h *NsResourceHandler) DeleteNsResource(tenantName, group, version, resource, name string) error { if err := validateGVRParams(group, version, resource); err != nil { return err } ns, err := h.getTenantNamespace(tenantName) if err != nil { return err } gvr := schema.GroupVersionResource{Group: group, Version: version, Resource: resource} err = k8s.Default().DynamicClient.Resource(gvr).Namespace(ns).Delete(context.Background(), name, metav1.DeleteOptions{}) if errors.IsNotFound(err) { return nil } return err } func (h *NsResourceHandler) getTenantNamespace(tenantName string) (string, error) { tenant, err := db.GetManager().TenantDao().GetTenantIDByName(tenantName) if err != nil { return "", fmt.Errorf("tenant %s not found: %v", tenantName, err) } if tenant.Namespace != "" { return tenant.Namespace, nil } return tenant.UUID, nil } func injectSourceLabel(labels map[string]string, source string) { labels["rainbond.io/source"] = source } func decodeNsResourceDocuments(yamlBody []byte) ([]*unstructured.Unstructured, error) { decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(yamlBody), 4096) documents := make([]*unstructured.Unstructured, 0) for { obj := &unstructured.Unstructured{} if err := decoder.Decode(obj); err != nil { if err == io.EOF { break } return nil, httputil.NewErrBadRequest(fmt.Errorf("invalid YAML: %v", err)) } if len(obj.Object) == 0 { continue } documents = append(documents, obj) } if len(documents) == 0 { return nil, httputil.NewErrBadRequest(fmt.Errorf("invalid YAML: no resource documents found")) } return documents, nil } func (h *NsResourceHandler) createSingleNsResource(dynamicClient dynamic.Interface, mapper k8smeta.RESTMapper, teamNamespace, source string, obj *unstructured.Unstructured, result *NsResourceCreateResult, observe func(bool, error)) { mapping, err := resolveNsResourceMapping(mapper, obj) if err != nil { result.Message = err.Error() return } namespaceableClient := dynamicClient.Resource(mapping.Resource) resourceClient := dynamic.ResourceInterface(namespaceableClient) if mapping.Scope.Name() == k8smeta.RESTScopeNameNamespace { namespace := teamNamespace obj.SetNamespace(namespace) result.Namespace = namespace result.ResourceScope = "namespaced" resourceClient = namespaceableClient.Namespace(namespace) } else { obj.SetNamespace("") result.Namespace = "" result.ResourceScope = "cluster" } labels := obj.GetLabels() if labels == nil { labels = map[string]string{} } injectSourceLabel(labels, source) obj.SetLabels(labels) observe(true, nil) created, err := resourceClient.Create(context.Background(), obj, metav1.CreateOptions{}) observe(false, err) if err != nil { result.Message = err.Error() return } result.Success = true result.Name = created.GetName() result.APIVersion = created.GetAPIVersion() result.Kind = created.GetKind() result.Namespace = created.GetNamespace() if result.ResourceScope == "" { if created.GetNamespace() == "" { result.ResourceScope = "cluster" } else { result.ResourceScope = "namespaced" } } result.Message = "created" } func resolveNsResourceMapping(mapper k8smeta.RESTMapper, obj *unstructured.Unstructured) (*k8smeta.RESTMapping, error) { if mapper == nil { return nil, fmt.Errorf("kubernetes rest mapper is not initialized") } gvk := obj.GroupVersionKind() if gvk.Kind == "" || gvk.Version == "" { return nil, fmt.Errorf("resource kind and apiVersion are required") } mapping, err := mapper.RESTMapping(gvk.GroupKind(), gvk.Version) if err != nil { return nil, err } return mapping, nil } func buildNsResourceCreateSummary(results []NsResourceCreateResult) NsResourceCreateSummary { summary := NsResourceCreateSummary{Total: len(results)} for _, result := range results { if result.Success { summary.SuccessCount++ continue } summary.FailureCount++ } summary.PartialSuccess = summary.SuccessCount > 0 && summary.FailureCount > 0 return summary } func buildNsResourceCreateMessage(summary NsResourceCreateSummary) string { switch { case summary.Total == 0: return "未解析到可创建的资源" case summary.FailureCount == 0: return fmt.Sprintf("共创建 %d 个资源,全部成功", summary.Total) case summary.SuccessCount == 0: return fmt.Sprintf("共创建 %d 个资源,全部失败", summary.Total) default: return fmt.Sprintf("共创建 %d 个资源,%d 个成功,%d 个失败", summary.Total, summary.SuccessCount, summary.FailureCount) } } func nsResourceCreateStatusCode(summary NsResourceCreateSummary) int { switch { case summary.Total == 0: return 400 case summary.FailureCount == 0: return 200 case summary.SuccessCount == 0: return 400 default: return 207 } } func detectResourceSource(labels map[string]string) string { if labels == nil { return "external" } if v, ok := labels["app.kubernetes.io/managed-by"]; ok && v == "Helm" { return "helm" } if v, ok := labels["rainbond.io/source"]; ok { return v } return "external" } func toNsResourceInfo(obj unstructured.Unstructured) NsResourceInfo { source := detectResourceSource(obj.GetLabels()) info := NsResourceInfo{ Name: obj.GetName(), Kind: obj.GetKind(), APIVersion: obj.GetAPIVersion(), Source: source, CreatedAt: formatResourceTimestamp(obj.GetCreationTimestamp().Time), Status: computeNsResourceStatus(obj), } fillNsResourceInfo(&info, obj) return info } func computeNsResourceStatus(obj unstructured.Unstructured) string { switch obj.GetKind() { case "Deployment", "StatefulSet": available, _, _ := unstructured.NestedInt64(obj.Object, "status", "availableReplicas") desired, _, _ := unstructured.NestedInt64(obj.Object, "spec", "replicas") if desired > 0 && available == desired { return "running" } return "warning" case "DaemonSet": ready, _, _ := unstructured.NestedInt64(obj.Object, "status", "numberReady") desired, _, _ := unstructured.NestedInt64(obj.Object, "status", "desiredNumberScheduled") if desired > 0 && ready == desired { return "running" } return "warning" case "Pod": phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") if phase == "" { return "unknown" } return phase case "PersistentVolumeClaim": phase, _, _ := unstructured.NestedString(obj.Object, "status", "phase") if phase == "Bound" { return "bound" } return phase default: return "active" } } func fillNsResourceInfo(info *NsResourceInfo, obj unstructured.Unstructured) { switch obj.GetKind() { case "Deployment", "StatefulSet": info.Replicas, _, _ = unstructured.NestedInt64(obj.Object, "spec", "replicas") info.ReadyReplicas, _, _ = unstructured.NestedInt64(obj.Object, "status", "readyReplicas") case "DaemonSet": info.Replicas, _, _ = unstructured.NestedInt64(obj.Object, "status", "desiredNumberScheduled") info.ReadyReplicas, _, _ = unstructured.NestedInt64(obj.Object, "status", "numberReady") case "Pod": info.Node, _, _ = unstructured.NestedString(obj.Object, "spec", "nodeName") info.PodIP, _, _ = unstructured.NestedString(obj.Object, "status", "podIP") for _, owner := range obj.GetOwnerReferences() { if owner.Controller != nil && *owner.Controller { info.Owner = owner.Name break } } statuses, _, _ := unstructured.NestedSlice(obj.Object, "status", "containerStatuses") var restartCount int64 for _, item := range statuses { if statusMap, ok := item.(map[string]interface{}); ok { switch count := statusMap["restartCount"].(type) { case int64: restartCount += count case int32: restartCount += int64(count) case float64: restartCount += int64(count) } } } info.RestartCount = int32(restartCount) case "Service": info.Type, _, _ = unstructured.NestedString(obj.Object, "spec", "type") info.ClusterIP, _, _ = unstructured.NestedString(obj.Object, "spec", "clusterIP") info.Selector, _, _ = unstructured.NestedStringMap(obj.Object, "spec", "selector") var ports []corev1.ServicePort portList, _, _ := unstructured.NestedSlice(obj.Object, "spec", "ports") for _, item := range portList { if portMap, ok := item.(map[string]interface{}); ok { var port corev1.ServicePort switch p := portMap["port"].(type) { case int64: port.Port = int32(p) case int32: port.Port = p case float64: port.Port = int32(p) } if protocol, ok := portMap["protocol"].(string); ok { port.Protocol = corev1.Protocol(protocol) } if targetPort, ok := portMap["targetPort"].(string); ok { port.TargetPort = intstrFromString(targetPort) } else { switch targetPort := portMap["targetPort"].(type) { case int64: port.TargetPort = intstrFromInt(targetPort) case int32: port.TargetPort = intstr.FromInt(int(targetPort)) case float64: port.TargetPort = intstr.FromInt(int(targetPort)) } } ports = append(ports, port) } } info.Ports = ports case "Ingress": info.IngressClass, _, _ = unstructured.NestedString(obj.Object, "spec", "ingressClassName") info.Hosts = extractIngressHosts(obj) info.TLSHosts = extractIngressTLSHosts(obj) info.BackendServices = extractIngressBackendServices(obj) case "ConfigMap", "Secret": data, _, _ := unstructured.NestedMap(obj.Object, "data") if len(data) == 0 { data, _, _ = unstructured.NestedMap(obj.Object, "stringData") } info.DataCount = len(data) case "PersistentVolumeClaim": info.Storage, _, _ = unstructured.NestedString(obj.Object, "spec", "resources", "requests", "storage") info.StorageClass, _, _ = unstructured.NestedString(obj.Object, "spec", "storageClassName") info.VolumeName, _, _ = unstructured.NestedString(obj.Object, "spec", "volumeName") accessModes, _, _ := unstructured.NestedStringSlice(obj.Object, "spec", "accessModes") info.AccessModes = accessModes } } func extractIngressHosts(obj unstructured.Unstructured) []string { rules, _, _ := unstructured.NestedSlice(obj.Object, "spec", "rules") hosts := make([]string, 0, len(rules)) for _, item := range rules { rule, ok := item.(map[string]interface{}) if !ok { continue } if host, ok := rule["host"].(string); ok && host != "" { hosts = append(hosts, host) } } return uniqueStrings(hosts) } func extractIngressTLSHosts(obj unstructured.Unstructured) []string { tlsList, _, _ := unstructured.NestedSlice(obj.Object, "spec", "tls") hosts := make([]string, 0, len(tlsList)) for _, item := range tlsList { tls, ok := item.(map[string]interface{}) if !ok { continue } rawHosts, ok := tls["hosts"].([]interface{}) if !ok { continue } for _, rawHost := range rawHosts { if host, ok := rawHost.(string); ok && host != "" { hosts = append(hosts, host) } } } return uniqueStrings(hosts) } func extractIngressBackendServices(obj unstructured.Unstructured) []string { services := make([]string, 0, 4) if serviceName, _, _ := unstructured.NestedString(obj.Object, "spec", "defaultBackend", "service", "name"); serviceName != "" { services = append(services, serviceName) } rules, _, _ := unstructured.NestedSlice(obj.Object, "spec", "rules") for _, item := range rules { rule, ok := item.(map[string]interface{}) if !ok { continue } paths, _, _ := unstructured.NestedSlice(rule, "http", "paths") for _, pathItem := range paths { path, ok := pathItem.(map[string]interface{}) if !ok { continue } if serviceName, _, _ := unstructured.NestedString(path, "backend", "service", "name"); serviceName != "" { services = append(services, serviceName) } } } return uniqueStrings(services) } func intstrFromString(value string) intstr.IntOrString { return intstr.FromString(value) } func intstrFromInt(value int64) intstr.IntOrString { return intstr.FromInt(int(value)) } var nsResourceHandler *NsResourceHandler // GetNsResourceHandler returns the singleton NsResourceHandler func GetNsResourceHandler() *NsResourceHandler { if nsResourceHandler == nil { nsResourceHandler = &NsResourceHandler{} } return nsResourceHandler }