diff --git a/backend/internal/api/handlers/k8s_export.go b/backend/internal/api/handlers/k8s_export.go index 657f5a5..23f134c 100644 --- a/backend/internal/api/handlers/k8s_export.go +++ b/backend/internal/api/handlers/k8s_export.go @@ -16,9 +16,122 @@ import ( // yamlEntry 表示一个待打包的 YAML 资源 type yamlEntry struct { - folder string // tar 内子目录 - name string // 文件名(不含 .yaml) - yaml []byte // 清理后的 YAML + namespace string // 所属命名空间(用于 tar 内路径) + folder string // tar 内子目录 + name string // 文件名(不含 .yaml) + yaml []byte // 清理后的 YAML +} + +// ExportResourcesAllNS 导出集群所有命名空间的 Deployment+Service+Ingress+ConfigMap+Secret 为 tar.gz +func (h *KubernetesHandler) ExportResourcesAllNS(c *gin.Context) { + clusterID := h.resolveClusterCode(c.Param("clusterCode")) + if clusterID == 0 { + c.JSON(500, gin.H{"code": 500, "message": "cluster not found"}) + return + } + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, gin.H{"code": 500, "message": err.Error()}) + return + } + + // 获取所有命名空间 + namespaces, err := client.ListNamespaces() + if err != nil { + c.JSON(500, gin.H{"code": 500, "message": "list namespaces failed: " + err.Error()}) + return + } + + // 资源类型 → API 路径模板 + 子目录名 + type resourceDef struct { + apiPathTmpl string // %s = namespace + folder string + kind string + apiVersion string + } + resourceDefs := []resourceDef{ + {"/apis/apps/v1/namespaces/%s/deployments", "deployments", "Deployment", "apps/v1"}, + {"/api/v1/namespaces/%s/services", "services", "Service", "v1"}, + {"/apis/networking.k8s.io/v1/namespaces/%s/ingresses", "ingresses", "Ingress", "networking.k8s.io/v1"}, + {"/api/v1/namespaces/%s/configmaps", "configmaps", "ConfigMap", "v1"}, + {"/api/v1/namespaces/%s/secrets", "secrets", "Secret", "v1"}, + } + + var entries []yamlEntry + totalCount := 0 + + for _, ns := range namespaces { + for _, rd := range resourceDefs { + listPath := fmt.Sprintf(rd.apiPathTmpl, ns) + yamlBytes, err := client.GetResourceYAML(listPath) + if err != nil { + log.Printf("[ExportAllNS] %s/%s list failed: %v", ns, rd.folder, err) + continue + } + + var listObj map[string]interface{} + if err := yaml.Unmarshal(yamlBytes, &listObj); err != nil { + log.Printf("[ExportAllNS] %s/%s parse failed: %v", ns, rd.folder, err) + continue + } + + items, ok := listObj["items"].([]interface{}) + if !ok || len(items) == 0 { + continue + } + + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + + meta, _ := itemMap["metadata"].(map[string]interface{}) + name, _ := meta["name"].(string) + if name == "" { + continue + } + + itemMap["apiVersion"] = rd.apiVersion + itemMap["kind"] = rd.kind + cleanResourceYAML(itemMap) + + cleanedBytes, err := yaml.Marshal(itemMap) + if err != nil { + log.Printf("[ExportAllNS] marshal %s/%s/%s failed: %v", ns, rd.folder, name, err) + continue + } + + entries = append(entries, yamlEntry{ + namespace: ns, + folder: rd.folder, + name: name, + yaml: cleanedBytes, + }) + totalCount++ + } + } + } + + if totalCount == 0 { + c.JSON(404, gin.H{"code": 404, "message": "集群没有可导出的资源"}) + return + } + + tarGz, err := buildTarGzAllNS(entries) + if err != nil { + c.JSON(500, gin.H{"code": 500, "message": "打包失败: " + err.Error()}) + return + } + + clusterName := c.Param("clusterCode") + filename := fmt.Sprintf("cluster-%s-%s.tar.gz", clusterName, time.Now().Format("20060102-150405")) + c.Header("Content-Type", "application/gzip") + c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + c.Header("Content-Length", fmt.Sprintf("%d", len(tarGz))) + c.Data(http.StatusOK, "application/gzip", tarGz) + + log.Printf("[ExportAllNS] exported %d resources across %d namespaces (cluster %d)", totalCount, len(namespaces), clusterID) } // ExportResourcesNS 导出指定命名空间的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz @@ -212,15 +325,48 @@ func cleanRuntimeAnnotations(annotations map[string]interface{}) { } } -// buildTarGz 构建 tar.gz 包 +// buildTarGz 构建 tar.gz 包(单命名空间,路径: ns/folder/name.yaml) func buildTarGz(entries []yamlEntry, namespace string) ([]byte, error) { var buf bytes.Buffer gw := gzip.NewWriter(&buf) tw := tar.NewWriter(gw) for _, e := range entries { - // 路径: {namespace}/{folder}/{name}.yaml - path := fmt.Sprintf("%s/%s/%s.yaml", namespace, e.folder, e.name) + ns := e.namespace + if ns == "" { + ns = namespace + } + path := fmt.Sprintf("%s/%s/%s.yaml", ns, e.folder, e.name) + header := &tar.Header{ + Name: path, + Mode: 0644, + Size: int64(len(e.yaml)), + } + if err := tw.WriteHeader(header); err != nil { + return nil, fmt.Errorf("tar write header %s: %w", path, err) + } + if _, err := io.Copy(tw, bytes.NewReader(e.yaml)); err != nil { + return nil, fmt.Errorf("tar write body %s: %w", path, err) + } + } + + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("tar close: %w", err) + } + if err := gw.Close(); err != nil { + return nil, fmt.Errorf("gzip close: %w", err) + } + return buf.Bytes(), nil +} + +// buildTarGzAllNS 构建 tar.gz 包(多命名空间,路径: ns/folder/name.yaml) +func buildTarGzAllNS(entries []yamlEntry) ([]byte, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for _, e := range entries { + path := fmt.Sprintf("%s/%s/%s.yaml", e.namespace, e.folder, e.name) header := &tar.Header{ Name: path, Mode: 0644, diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 2102a15..7227782 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -398,6 +398,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // 🆕 YAML Apply kubernetesRouter.POST("/:clusterCode/yaml/apply", k8sHandler.ApplyYAML) // 🆕 批量导出资源 (Deployment+Service+Ingress+ConfigMap+Secret) // Logan.Li + kubernetesRouter.GET("/:clusterCode/export", k8sHandler.ExportResourcesAllNS) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/export", k8sHandler.ExportResourcesNS) // PVC (存储) diff --git a/frontend/src/components/kubernetes/KubernetesClusters.tsx b/frontend/src/components/kubernetes/KubernetesClusters.tsx index 0fe036d..04703e3 100644 --- a/frontend/src/components/kubernetes/KubernetesClusters.tsx +++ b/frontend/src/components/kubernetes/KubernetesClusters.tsx @@ -52,7 +52,6 @@ export function KubernetesClusters() { // 导出资源弹窗 const [exportCluster, setExportCluster] = useState(null); - const [exportNs, setExportNs] = useState('default'); const [exporting, setExporting] = useState(false); const loadData = async () => { @@ -113,11 +112,10 @@ export function KubernetesClusters() { const handleExport = async () => { if (!exportCluster) return; const code = exportCluster.code || String(exportCluster.id); - if (!exportNs.trim()) { toast.error('请输入命名空间'); return; } try { setExporting(true); - toast.info(`正在导出 ${exportNs} 命名空间的资源...`); - await kubernetesApi.exportResources(code, exportNs.trim()); + toast.info(`正在导出 ${exportCluster.name} 所有命名空间的资源...`); + await kubernetesApi.exportAllResources(code); toast.success('导出完成,已开始下载'); setExportCluster(null); } catch (e: any) { @@ -359,7 +357,6 @@ export function KubernetesClusters() { onClick={(e) => { e.stopPropagation(); setExportCluster(cluster); - setExportNs('default'); }} > @@ -473,16 +470,8 @@ export function KubernetesClusters() {
e.stopPropagation()}>

导出集群资源

- 导出 {exportCluster.name} 的 Deployment + Service + Ingress + ConfigMap + Secret 为 tar.gz + 导出 {exportCluster.name} 所有命名空间的 Deployment、Service、Ingress、ConfigMap、Secret 为 tar.gz

- - setExportNs(e.target.value)} - placeholder="default" - className="bg-slate-800 border-slate-700 text-slate-100 mb-4" - onKeyDown={(e) => e.key === 'Enter' && handleExport()} - />