feat(k8s): M3 Pod文件上传 + ExecInPod WebSocket协议重写
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 日期格式
This commit is contained in:
1 parent
69df073d42
commit
9ab7572b8e
11 files changed
+389
-126
No files matched your search
+2
-1
@@ -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 懒加载后生效)
|
||||
|
||||
@@ -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 | 菜单无此项 |
|
||||
|
||||
|
||||
+8
-5
@@ -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 字段返回正确文件大小
|
||||
|
||||
---
|
||||
|
||||
|
||||
+7
-1
@@ -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 格式)
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
// 选中的条目(用于高亮)
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
|
||||
@@ -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({
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => loadPath(currentPath)} title="刷新">
|
||||
<RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传文件"
|
||||
>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => handleUpload(e.target.files)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
|
||||
@@ -249,12 +249,13 @@ export const kubernetesApi = {
|
||||
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/exec`, { command, container });
|
||||
},
|
||||
// 🆕 Pod 级 exec WebSocket URL (Logan <admin@attacker.club>)
|
||||
// ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&token=
|
||||
// ws://host/ws/kubernetes-exec/:clusterId/namespaces/:ns/pods/:pod?container=&command=&token=
|
||||
buildPodExecWsUrl: (
|
||||
clusterId: number | string,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
container?: string
|
||||
container?: string,
|
||||
command?: string
|
||||
) => {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${proto}//${window.location.host}`;
|
||||
@@ -262,6 +263,7 @@ export const kubernetesApi = {
|
||||
const token = useUserStore.getState().token || '';
|
||||
const params = new URLSearchParams();
|
||||
if (container) params.set('container', container);
|
||||
if (command) params.set('command', command);
|
||||
if (token) params.set('token', token);
|
||||
const qs = params.toString();
|
||||
return `${wsBase}/ws/kubernetes-exec/${clusterId}/namespaces/${namespace}/pods/${podName}${qs ? `?${qs}` : ''}`;
|
||||
@@ -309,6 +311,33 @@ export const kubernetesApi = {
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
return resp.text();
|
||||
},
|
||||
// 上传文件到 Pod 容器内指定目录
|
||||
uploadPodFile: async (
|
||||
clusterCode: string,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
targetPath: string,
|
||||
file: File,
|
||||
container?: string,
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', targetPath);
|
||||
if (container) formData.append('container', container);
|
||||
const token = useUserStore.getState().token || localStorage.getItem('token') || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
|
||||
const resp = await fetch(
|
||||
`${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/files/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const json = await resp.json();
|
||||
if (!resp.ok || json.code !== 200) throw new Error(json.message || `HTTP ${resp.status}`);
|
||||
return json.data;
|
||||
},
|
||||
scaleDeployment: async (clusterCode: string, namespace: string, name: string, replicas: number) => {
|
||||
return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/scale`, { replicas });
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user