From 9ab7572b8ef01b1f541662a7822097ce9d67e7d5 Mon Sep 17 00:00:00 2001 From: attacker Date: Tue, 23 Jun 2026 02:21:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(k8s):=20M3=20Pod=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=20+=20ExecInPod=20WebSocket=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3: Pod 文件上传 - 后端: POST .../pods/:pod/files/upload (multipart, base64分块exec写入) - 前端: PodFileBrowser 增加上传按钮 + FormData API - 路由: 新增 Kuboard (:clusterCode) + 旧 (:id) 两套路由 重大修复: ExecInPod 从 raw HTTP SPDY 改为 WebSocket channel.k8s.io - 旧方案: HTTP POST + SPDY upgrade → 无法解析响应(协议头缺失 + 帧解析复杂) - 新方案: gorilla/websocket Dialer + channel.k8s.io 子协议 → 和 ExecTerminal 一致 - 影响: 文件浏览/下载/上传全部恢复正常 修复: BusyBox ls 解析 - BusyBox 不支持 --time-style 参数,默认输出 Mon DD HH:MM 格式 - 解析器改为自动检测 GNU vs BusyBox 日期格式 --- Dockerfile.embed | 3 +- PROJECT_EVALUATION.md | 4 +- ROADMAP.md | 13 +- TDD_PLAN.md | 8 +- backend/Dockerfile | 3 +- backend/internal/api/handlers/kubernetes.go | 287 +++++++++++++----- backend/internal/api/routes/routes.go | 2 + backend/pkg/kubernetesclient/client.go | 113 ++++--- .../components/kubernetes/PodExecTerminal.tsx | 6 +- .../components/kubernetes/PodFileBrowser.tsx | 43 ++- frontend/src/services/api.ts | 33 +- 11 files changed, 389 insertions(+), 126 deletions(-) diff --git a/Dockerfile.embed b/Dockerfile.embed index 86bc128..e1bbb57 100644 --- a/Dockerfile.embed +++ b/Dockerfile.embed @@ -86,7 +86,8 @@ RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories # 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li # bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送 -# kubectl/maven/nodejs/ansible → 见「发布配置→工具配置」Tab,首次使用自动下载 +# kubectl/maven/nodejs/helm → 见「发布配置→工具配置」Tab,首次使用自动下载 +# Pod文件浏览/exec 通过 WebSocket channel 协议直连 K8s API,无需 kubectl RUN apk --no-cache add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx # Maven 阿里云镜像加速(maven 懒加载后生效) diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index 2324496..f0ceaa1 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -26,7 +26,7 @@ | Deployment 历史版本/回滚 | ✅ revisions/rollback | ✅ | ✅ | | Pod 日志(实时流) | ✅ GetPodLogs | ✅ PodLogsViewer fetch stream | ✅ | | Pod 终端 exec | ✅ ExecTerminal(WS) | ✅ PodExecTerminal bash/sh/ash | ✅ | -| Pod 文件浏览 + 下载 | ✅ files/list + files/download | ✅ PodFileBrowser | ✅ | +| Pod 文件浏览 + 下载 + 上传 | ✅ files/list + files/download + files/upload | ✅ PodFileBrowser | ✅ | | YAML 查看/编辑 | ✅ GetYAML + UpdateYAML | ✅ YamlViewer | ✅ | | ReplicaSet 列表 | ✅ | ✅ | ✅ | | Service/Ingress 列表 | ✅ | ✅ | ✅ | @@ -41,7 +41,7 @@ | 功能 | 优先级 | 说明 | |------|:------:|------| -| Pod 文件上传 | P2 | 只有下载,缺上传 | +| Pod 文件上传 | ✅ | P2 | ✅ 已完成 M3 | | CI/CD 集成菜单项 | P3 | 菜单有按钮但未接线 | | Deployment 暂停更新 | P3 | 菜单无此项 | diff --git a/ROADMAP.md b/ROADMAP.md index 03dc514..58c8a4a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,16 +38,19 @@ --- -### M3: Pod 文件上传 [pending] +### M3: Pod 文件上传 [done] **目标**: PodFileBrowser 增加上传能力 **改动**: -- 后端新增 `POST .../pods/:pod/files/upload` — 用 exec tar 解包写入 -- 前端 PodFileBrowser 增加上传按钮 + 拖拽上传 +- 后端新增 `POST .../pods/:pod/files/upload` — multipart 表单接收,base64 分块 exec 写入 +- 前端 PodFileBrowser 增加上传按钮 + FormData 上传 +- **重大修复**: `ExecInPod` 从 raw HTTP SPDY(不工作)改为 WebSocket channel.k8s.io 协议(和 ExecTerminal 一致) +- **修复**: BusyBox `ls` 不支持 `--time-style`,解析器改为兼容 BusyBox 默认日期格式 **验收标准**: -- [ ] 可上传文件到 Pod 容器内 -- [ ] 上传后文件列表刷新可见 +- [x] 可上传文件到 Pod 容器内 +- [x] 上传后文件列表刷新可见 +- [x] verify 字段返回正确文件大小 --- diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 952f759..9664416 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -84,7 +84,13 @@ curl -s "http://127.0.0.1:8080/api/v1/kubernetes-clusters/1/pods" \ # 上传文件 echo "test content" > /tmp/test-upload.txt curl -X POST "http://127.0.0.1:8080/api/v1/kubernetes/1/namespace/devops/pods/aiops-xxx/files/upload?path=/tmp&container=aiops" \ - -H "Authorization: Bearer $TOKEN" \ + -H "Authorization: Bearer ***" \ -F "file=@/tmp/test-upload.txt" # 期望: 200 + 文件出现在列表中 ``` + +### 通过标准 +- [x] 上传 API 返回 200 + verify 字段匹配文件大小 +- [x] 文件列表 (ls) 能看到上传的文件 +- [x] ExecInPod 使用 WebSocket channel.k8s.io 协议(不再用 SPDY raw HTTP) +- [x] BusyBox ls 解析兼容(Mon DD HH:MM 格式) diff --git a/backend/Dockerfile b/backend/Dockerfile index 71fe94a..b677f3b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -78,7 +78,8 @@ RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories # 安装运行时依赖(核心最小集,重型工具改为懒加载)// Logan.Li # bash=kubectl交互终端, curl=健康检查, git=流水线代码拉取, docker-cli=镜像构建推送 -# kubectl/maven/nodejs/ansible → 见「发布配置→工具配置」Tab,首次使用自动下载 +# kubectl/maven/nodejs/helm → 见「发布配置→工具配置」Tab,首次使用自动下载 +# Pod文件浏览/exec 通过 WebSocket channel 协议直连 K8s API,无需 kubectl RUN --mount=type=cache,target=/var/cache/apk \ apk add ca-certificates tzdata bash curl git docker-cli docker-cli-buildx diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index c74b822..5f09701 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -1,11 +1,13 @@ package handlers import ( + "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" + "net/url" "path" "sort" "strconv" @@ -16,6 +18,7 @@ import ( "ezops/pkg/kubernetesclient" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "gorm.io/gorm" ) @@ -916,7 +919,22 @@ func (h *KubernetesHandler) GetPodLogs(c *gin.Context) { // WebSocket Exec Terminal (交互式 bash) // ============================================================================ -// ExecTerminal WebSocket 终端 — 前端 WS → 后端 → K8s SPDY exec +// ExecTerminal WebSocket 终端 — 前端 WS → 后端 → K8s WebSocket channel 协议 +// +// 直接通过 K8s API 的 exec 端点(WebSocket channel.k8s.io v4 协议)建立 +// 交互式终端,无需在容器中安装 kubectl。 +// +// 数据流: +// +// Frontend WS (JSON) ←→ Backend ←→ K8s API WS (channel frames) +// +// K8s channel 协议: 每条消息 [1字节 channel ID] + [payload] +// +// 0 = stdin (client→server) +// 1 = stdout (server→client) +// 2 = stderr (server→client) +// 3 = error (server→client, JSON) +// 4 = resize (client→server, JSON {"Height","Width"}) func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { clusterID, _ := strconv.Atoi(c.Param("id")) namespace := c.Param("namespace") @@ -927,102 +945,128 @@ func (h *KubernetesHandler) ExecTerminal(c *gin.Context) { command = "sh" } - client, _, err := h.getK8sClient(clusterID) + client, cluster, err := h.getK8sClient(clusterID) if err != nil { c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()}) return } - // Build exec URL with SPDY upgrade - execPath := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?command=%s&stdin=true&stdout=true&stderr=true&tty=true", - namespace, podName, command) + // Build exec WebSocket URL — https→wss / http→ws + execURL := client.BaseURL + if strings.HasPrefix(execURL, "https://") { + execURL = "wss://" + strings.TrimPrefix(execURL, "https://") + } else { + execURL = "ws://" + strings.TrimPrefix(execURL, "http://") + } + params := url.Values{} + params.Set("command", command) + params.Set("stdin", "true") + params.Set("stdout", "true") + params.Set("stderr", "true") + params.Set("tty", "true") if container != "" { - execPath += "&container=" + container + params.Set("container", container) } + execURL += fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s", + url.PathEscape(namespace), url.PathEscape(podName), params.Encode()) - // Open SPDY connection to K8s API - k8sReq, err := http.NewRequest("POST", client.BaseURL+execPath, nil) + // Dial K8s API server via WebSocket with channel protocol subprotocol + k8sDialer := &websocket.Dialer{ + TLSClientConfig: client.TLSConfig, + Subprotocols: []string{"v4.channel.k8s.io", "v3.channel.k8s.io", "v5.channel.k8s.io"}, + HandshakeTimeout: 10 * time.Second, + } + k8sHeader := http.Header{} + if client.BearerToken != "" { + k8sHeader.Set("Authorization", "Bearer "+client.BearerToken) + } + k8sConn, _, err := k8sDialer.Dial(execURL, k8sHeader) if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "build request: " + err.Error()}) + log.Printf("[K8sExec] dial K8s exec WS failed: %v", err) + c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s exec 连接失败: " + err.Error()}) return } - k8sReq.Header.Set("Connection", "Upgrade") - k8sReq.Header.Set("Upgrade", "SPDY/3.1") - k8sReq.Header.Set("X-Stream-Protocol-Version", "v4.channel.k8s.io") - k8sReq.Header.Set("X-Stream-Protocol-Version", "v3.channel.k8s.io") + defer k8sConn.Close() - // Use raw TCP dial for SPDY upgrade - dialer := client.HTTPClient.Transport.(*http.Transport).DialContext - conn, err := dialer(c.Request.Context(), "tcp", strings.TrimPrefix(strings.TrimPrefix(client.BaseURL, "https://"), "http://")) - if err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "dial K8s: " + err.Error()}) - return - } - defer conn.Close() - - if err := k8sReq.Write(conn); err != nil { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "write request: " + err.Error()}) - return - } - - // Read the 101 Switching Protocols response - buf := make([]byte, 4096) - n, err := conn.Read(buf) - if err != nil || n == 0 { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "read upgrade response: " + fmt.Sprint(err)}) - return - } - respStr := string(buf[:n]) - if !strings.Contains(respStr, "101") { - c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "upgrade failed: " + respStr[:min(len(respStr), 200)]}) - return - } - - // Upgrade Gin to WebSocket - wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + // Upgrade frontend connection to WebSocket + frontendConn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } - defer wsConn.Close() + defer frontendConn.Close() - // Bidirectional proxy: WS ↔ SPDY done := make(chan struct{}, 2) - // WS → SPDY (stdin) + // Frontend WS → K8s WS (stdin + resize) go func() { defer func() { done <- struct{}{} }() for { - _, msg, err := wsConn.ReadMessage() + msgType, msg, err := frontendConn.ReadMessage() if err != nil { return } - // SPDY stdin frame: channel 0 - frame := make([]byte, len(msg)+8) - frame[0] = 0 // channel 0 (stdin) - copy(frame[8:], msg) - if _, err := conn.Write(frame); err != nil { - return + _ = msgType + + // Parse frontend JSON protocol: {status, data} or {status, cols, rows} + var wsMsg WSMessage + if err := json.Unmarshal(msg, &wsMsg); err != nil { + continue + } + + if wsMsg.Status == 0 && wsMsg.Data != "" { + // stdin: prepend channel 0 byte + frame := make([]byte, 1+len(wsMsg.Data)) + frame[0] = 0 // channel 0 = stdin + copy(frame[1:], wsMsg.Data) + if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return + } + } else if wsMsg.Status == 1 && wsMsg.Cols > 0 { + // resize: channel 4 + JSON + resizeJSON, _ := json.Marshal(struct { + Height uint16 `json:"Height"` + Width uint16 `json:"Width"` + }{Height: uint16(wsMsg.Rows), Width: uint16(wsMsg.Cols)}) + frame := make([]byte, 1+len(resizeJSON)) + frame[0] = 4 // channel 4 = resize + copy(frame[1:], resizeJSON) + if err := k8sConn.WriteMessage(websocket.BinaryMessage, frame); err != nil { + return + } } } }() - // SPDY → WS (stdout/stderr) + // K8s WS → Frontend WS (stdout/stderr/error) go func() { defer func() { done <- struct{}{} }() - buf := make([]byte, 4096) for { - n, err := conn.Read(buf) + msgType, msg, err := k8sConn.ReadMessage() if err != nil { + // K8s connection closed — notify frontend + frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[33m[K8s 连接已关闭]\x1b[0m\r\n"}) return } - if n > 8 { - // Skip SPDY header (8 bytes), send data - wsConn.WriteMessage(1, buf[8:n]) // 1 = TextMessage + _ = msgType + if len(msg) == 0 { + continue + } + + channel := msg[0] + payload := msg[1:] + + switch channel { + case 1, 2: // stdout / stderr → forward to frontend as terminal output + frontendConn.WriteJSON(WSMessage{Status: 0, Data: string(payload)}) + case 3: // error → forward as error message + errMsg := string(payload) + frontendConn.WriteJSON(WSMessage{Status: 2, Data: "\r\n\x1b[31m" + errMsg + "\x1b[0m\r\n"}) } } }() <-done + _ = cluster } // ============================================================================ @@ -2623,8 +2667,8 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { if req.Path == "" { req.Path = "/" } client, _, err := h.getK8sClient(clusterID) if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } - // ls -la 配合自定义 time-style 获取清晰可解析的字段 - cmd := []string{"ls", "-la", "--time-style=+%Y-%m-%dT%H:%M:%S", req.Path} + // ls -la — BusyBox ignores --time-style, use default "Mon DD HH:MM" format + cmd := []string{"ls", "-la", req.Path} result, err := client.ExecInPod(ns, podName, req.Container, cmd) if err != nil { c.JSON(500, models.Response{Code: 500, Message: "执行 ls 失败: " + err.Error()}); return } @@ -2636,7 +2680,7 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { IsDir bool `json:"is_dir"` } files := []FileInfo{} - output := cleanExecOutput(result.Stdout) + output := result.Stdout // WebSocket output is already clean text for _, line := range strings.Split(output, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "total ") { continue } @@ -2645,13 +2689,20 @@ func (h *KubernetesHandler) listPodFilesWithNS(c *gin.Context, ns string) { mode := fields[0] if len(mode) == 0 || (mode[0] != 'd' && mode[0] != '-' && mode[0] != 'l') { continue } size := fields[4] - modTime := fields[5] - // 文件名可能含空格:定位到 modTime 之后的内容作为 name - name := "" - if idx := strings.Index(line, modTime); idx >= 0 { - name = strings.TrimSpace(line[idx+len(modTime):]) + // BusyBox ls date format: "Jun 23 02:17" (3 fields: month day time) + // GNU ls --time-style: "2026-06-23T02:17:00" (1 field) + // Detect format: if fields[5] contains ":" it's time-only (BusyBox style) + var name, modTime string + if strings.Contains(fields[5], ":") || strings.Contains(fields[5], "T") { + // GNU style: single timestamp field + modTime = fields[5] + name = strings.Join(fields[6:], " ") + } else { + // BusyBox style: "Mon DD HH:MM" = 3 fields starting at index 5 + modTime = strings.Join(fields[5:8], " ") + name = strings.Join(fields[8:], " ") } - if name == "" { name = strings.Join(fields[6:], " ") } + if name == "" || name == "." || name == ".." { continue } files = append(files, FileInfo{ Name: name, Size: size, @@ -2679,9 +2730,109 @@ func (h *KubernetesHandler) downloadPodFileWithNS(c *gin.Context, ns string) { cmd := []string{"cat", filePath} result, err := client.ExecInPod(ns, podName, container, cmd) if err != nil { c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}); return } - content := cleanExecOutput(result.Stdout) + content := result.Stdout fileName := path.Base(filePath) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName)) c.Header("Content-Type", "application/octet-stream") c.String(200, content) } + +// UploadPodFileNS 上传文件到 Pod 容器内指定目录(通过 exec base64 分块写入) +func (h *KubernetesHandler) UploadPodFileNS(c *gin.Context) { + h.uploadPodFileWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) uploadPodFileWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { + clusterID = h.resolveClusterCode(c.Param("clusterCode")) + } + podName := c.Param("podName") + container := c.PostForm("container") + targetDir := c.PostForm("path") + if targetDir == "" { + targetDir = "/" + } + + // 读取上传的文件 + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(400, models.Response{Code: 400, Message: "文件上传失败: " + err.Error()}) + return + } + defer file.Close() + + // 限制文件大小 10MB + const maxUploadSize = 10 << 20 // 10MB + if header.Size > maxUploadSize { + c.JSON(400, models.Response{Code: 400, Message: fmt.Sprintf("文件过大: %d bytes, 上限 %d bytes", header.Size, maxUploadSize)}) + return + } + + content, err := io.ReadAll(file) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: "读取文件失败: " + err.Error()}) + return + } + + client, _, err := h.getK8sClient(clusterID) + if err != nil { + c.JSON(500, models.Response{Code: 500, Message: err.Error()}) + return + } + + // 目标完整路径 + targetFile := strings.TrimRight(targetDir, "/") + "/" + header.Filename + + // 确保目标目录存在 + _, _ = client.ExecInPod(ns, podName, container, []string{"mkdir", "-p", targetDir}) + + // base64 分块写入:每块 3000 字节 base64 (~2250 bytes 二进制),保证 URL 长度安全 + encoded := base64.StdEncoding.EncodeToString(content) + const chunkSize = 3000 + first := true + for i := 0; i < len(encoded); i += chunkSize { + end := i + chunkSize + if end > len(encoded) { + end = len(encoded) + } + chunk := encoded[i:end] + var cmd []string + if first { + // 首块:创建/覆盖文件 + cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d > '%s'", chunk, targetFile)} + first = false + } else { + // 后续块:追加 + cmd = []string{"sh", "-c", fmt.Sprintf("echo -n '%s' | base64 -d >> '%s'", chunk, targetFile)} + } + result, err := client.ExecInPod(ns, podName, container, cmd) + if err != nil { + stderr := "" + if result != nil { + stderr = result.Stderr + } + c.JSON(500, models.Response{Code: 500, Message: fmt.Sprintf("写入文件失败 (chunk %d): %s\n%s", i/chunkSize, err.Error(), stderr)}) + return + } + } + + // 验证文件大小 + verifyResult, verifyErr := client.ExecInPod(ns, podName, container, []string{"stat", "-c", "%s", targetFile}) + writtenSize := "" + if verifyErr == nil && verifyResult != nil { + writtenSize = strings.TrimSpace(verifyResult.Stdout) + } + + log.Printf("[UploadPodFile] %s/%s → %s (%d bytes, %d chunks)", ns, podName, targetFile, len(content), (len(encoded)+chunkSize-1)/chunkSize) + + c.JSON(200, models.Response{ + Code: 200, + Message: "上传成功", + Data: gin.H{ + "path": targetFile, + "name": header.Filename, + "size": len(content), + "verify": writtenSize, + }, + }) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index b541401..c6ac82d 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -317,6 +317,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // Pod 文件浏览 / 下载 / 删除 // Logan.Li clusters.POST("/:id/namespaces/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS) clusters.GET("/:id/namespaces/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS) + clusters.POST("/:id/namespaces/:namespace/pods/:podName/files/upload", k8sHandler.UploadPodFileNS) clusters.DELETE("/:id/namespaces/:namespace/pods/:podName", k8sHandler.DeletePodNS) // Namespace // Logan.Li clusters.GET("/:id/namespaces", k8sHandler.ListNamespaces) @@ -369,6 +370,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // 🆕 Pod 文件浏览 / 下载 / 删除 // Logan.Li kubernetesRouter.POST("/:clusterCode/namespace/:namespace/pods/:podName/files/list", k8sHandler.ListPodFilesNS) kubernetesRouter.GET("/:clusterCode/namespace/:namespace/pods/:podName/files/download", k8sHandler.DownloadPodFileNS) + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/pods/:podName/files/upload", k8sHandler.UploadPodFileNS) kubernetesRouter.DELETE("/:clusterCode/namespace/:namespace/pods/:podName", k8sHandler.DeletePodNS) // ConfigMap / Secret diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index e840722..cd50b6d 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -1,6 +1,7 @@ package k8sclient import ( + "bytes" "crypto/tls" "crypto/x509" "encoding/base64" @@ -8,9 +9,11 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" + "github.com/gorilla/websocket" "gopkg.in/yaml.v3" ) @@ -45,8 +48,11 @@ type KubeConfig struct { // Client wraps an HTTP client for K8s API calls type Client struct { - BaseURL string - HTTPClient *http.Client + BaseURL string + HTTPClient *http.Client + KubeconfigYAML string // raw kubeconfig for kubectl subprocess + TLSConfig *tls.Config + BearerToken string } // NewClient creates a K8s API client from kubeconfig YAML @@ -98,7 +104,8 @@ func NewClient(kubeconfigYAML string) (*Client, error) { tlsCfg.InsecureSkipVerify = true } - // Find user certs + // Find user creds (client certs and/or bearer token) + var bearerToken string for _, u := range kcfg.Users { if u.Name == userName { if u.User.ClientCertificateData != "" && u.User.ClientKeyData != "" { @@ -110,6 +117,9 @@ func NewClient(kubeconfigYAML string) (*Client, error) { } tlsCfg.Certificates = []tls.Certificate{cert} } + if u.User.Token != "" { + bearerToken = u.User.Token + } break } } @@ -122,8 +132,11 @@ func NewClient(kubeconfigYAML string) (*Client, error) { } return &Client{ - BaseURL: strings.TrimRight(server, "/"), - HTTPClient: httpClient, + BaseURL: strings.TrimRight(server, "/"), + HTTPClient: httpClient, + KubeconfigYAML: kubeconfigYAML, + TLSConfig: tlsCfg, + BearerToken: bearerToken, }, nil } @@ -573,7 +586,7 @@ func (c *Client) RestartDeployment(namespace, name string) error { return c.patch(path, body, nil) } -// --- Exec (non-interactive command) --- +// --- Exec (K8s WebSocket channel protocol) --- // ExecResult holds the output of a pod exec command type ExecResult struct { @@ -581,52 +594,70 @@ type ExecResult struct { Stderr string `json:"stderr"` } -// ExecInPod runs a command in a pod container and returns stdout/stderr +// ExecInPod runs a command in a pod container and returns stdout/stderr. +// Uses K8s WebSocket channel protocol (channel.k8s.io) — no kubectl needed. func (c *Client) ExecInPod(namespace, podName, container string, command []string) (*ExecResult, error) { - cmdStr := "" - for i, c := range command { - if i > 0 { - cmdStr += "&command=" - } - cmdStr += c + // Build exec WebSocket URL + execURL := c.BaseURL + if strings.HasPrefix(execURL, "https://") { + execURL = "wss://" + strings.TrimPrefix(execURL, "https://") + } else { + execURL = "ws://" + strings.TrimPrefix(execURL, "http://") } - path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s&stdout=true&stderr=true", - namespace, podName, cmdStr) + params := url.Values{} + for _, cmd := range command { + params.Add("command", cmd) + } + params.Set("stdin", "false") + params.Set("stdout", "true") + params.Set("stderr", "true") + params.Set("tty", "false") if container != "" { - path += "&container=" + container + params.Set("container", container) + } + execURL += fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s", + url.PathEscape(namespace), url.PathEscape(podName), params.Encode()) + + dialer := websocket.Dialer{ + TLSClientConfig: c.TLSConfig, + Subprotocols: []string{"channel.k8s.io"}, + HandshakeTimeout: 10 * time.Second, + } + header := http.Header{} + if c.BearerToken != "" { + header.Set("Authorization", "Bearer "+c.BearerToken) } - req, err := http.NewRequest("POST", c.BaseURL+path, nil) + conn, _, err := dialer.Dial(execURL, header) if err != nil { - return nil, err + return nil, fmt.Errorf("K8s exec WS dial: %w", err) } - // SPDY is the default for exec, but modern K8s also supports WebSocket. - // Fall back to SPDY upgrade attempt. - req.Header.Set("Connection", "Upgrade") - req.Header.Set("Upgrade", "SPDY/3.1") + defer conn.Close() - resp, err := c.HTTPClient.Do(req) - if err != nil { - return nil, fmt.Errorf("exec %s: %w", path, err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + var stdout, stderr bytes.Buffer + for { + _, msg, err := conn.ReadMessage() + if err != nil { + break // connection closed = command finished + } + if len(msg) == 0 { + continue + } + channel := msg[0] + payload := msg[1:] + switch channel { + case 1: + stdout.Write(payload) + case 2: + stderr.Write(payload) + case 3: + stderr.Write(payload) + } } - if resp.StatusCode >= 400 { - return nil, fmt.Errorf("exec error %d: %s", resp.StatusCode, string(body[:min(len(body), 500)])) - } - - // The exec response is multiplexed SPDY frames. For simple commands, - // the output comes in channel 1 (stdout) and channel 2 (stderr). - // Since we're not doing full SPDY parsing, we return the raw output - // and let the caller handle it. return &ExecResult{ - Stdout: string(body), - Stderr: "", + Stdout: stdout.String(), + Stderr: stderr.String(), }, nil } diff --git a/frontend/src/components/kubernetes/PodExecTerminal.tsx b/frontend/src/components/kubernetes/PodExecTerminal.tsx index d8e537e..921adfd 100644 --- a/frontend/src/components/kubernetes/PodExecTerminal.tsx +++ b/frontend/src/components/kubernetes/PodExecTerminal.tsx @@ -107,19 +107,17 @@ export function PodExecTerminal({ term.clear(); term.writeln(`\x1b[36m[正在连接 ${podName}${container ? '/' + container : ''} ...]\x1b[0m`); - const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined); + const wsUrl = kubernetesApi.buildPodExecWsUrl(clusterId, namespace, podName, container || undefined, shell); const ws = new WebSocket(wsUrl); wsRef.current = ws; ws.onopen = () => { setConnState('connected'); term.reset(); - // 发送初始 resize + 启动 shell + // 发送初始 resize(command/shell 已在 WS URL 参数中指定) try { const { cols, rows } = term; ws.send(JSON.stringify({ status: 1, cols, rows })); - // 启动 shell 进程:标准 remote exec 协议 (status:0 data) - ws.send(JSON.stringify({ status: 0, data: `${shell}\r` })); } catch { /* noop */ } setTimeout(() => { try { term.focus(); } catch { /* noop */ } }, 100); }; diff --git a/frontend/src/components/kubernetes/PodFileBrowser.tsx b/frontend/src/components/kubernetes/PodFileBrowser.tsx index 77e7733..24d4487 100644 --- a/frontend/src/components/kubernetes/PodFileBrowser.tsx +++ b/frontend/src/components/kubernetes/PodFileBrowser.tsx @@ -15,7 +15,7 @@ import { } from '@/components/ui/select'; import { Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage, - File, Download, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp, + File, Download, Upload, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp, RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive, AlertCircle, FileType, Binary, } from 'lucide-react'; @@ -156,6 +156,10 @@ export function PodFileBrowser({ // 预览 const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null); + // 上传 + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + // 选中的条目(用于高亮) const [selected, setSelected] = useState(''); @@ -287,6 +291,26 @@ export function PodFileBrowser({ toast({ title: '已开始下载', description: path }); }, [clusterCode, namespace, podName, container, toast]); + // 上传文件 + const handleUpload = useCallback(async (files: FileList | null) => { + if (!files || files.length === 0) return; + setUploading(true); + try { + for (const file of Array.from(files)) { + await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined); + toast({ title: '上传成功', description: `${file.name} → ${currentPath}/${file.name}` }); + } + // 刷新文件列表 + loadPath(currentPath); + } catch (e: any) { + toast({ title: '上传失败', description: e?.message || String(e), variant: 'destructive' }); + } finally { + setUploading(false); + // 清空 input 允许重复上传同一文件 + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }, [clusterCode, namespace, podName, container, currentPath, loadPath, toast]); + const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]); return ( @@ -331,6 +355,23 @@ export function PodFileBrowser({ + + handleUpload(e.target.files)} + />