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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user