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 日期格式
1518 lines
43 KiB
Go
1518 lines
43 KiB
Go
package k8sclient
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/tls"
|
||
"crypto/x509"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gorilla/websocket"
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// KubeConfig represents a parsed kubeconfig
|
||
type KubeConfig struct {
|
||
APIVersion string `yaml:"apiVersion"`
|
||
Clusters []struct {
|
||
Name string `yaml:"name"`
|
||
Cluster struct {
|
||
Server string `yaml:"server"`
|
||
CertificateAuthorityData string `yaml:"certificate-authority-data"`
|
||
InsecureSkipTLSVerify bool `yaml:"insecure-skip-tls-verify"`
|
||
} `yaml:"cluster"`
|
||
} `yaml:"clusters"`
|
||
Users []struct {
|
||
Name string `yaml:"name"`
|
||
User struct {
|
||
ClientCertificateData string `yaml:"client-certificate-data"`
|
||
ClientKeyData string `yaml:"client-key-data"`
|
||
Token string `yaml:"token"`
|
||
} `yaml:"user"`
|
||
} `yaml:"users"`
|
||
Contexts []struct {
|
||
Name string `yaml:"name"`
|
||
Context struct {
|
||
Cluster string `yaml:"cluster"`
|
||
User string `yaml:"user"`
|
||
} `yaml:"context"`
|
||
} `yaml:"contexts"`
|
||
CurrentContext string `yaml:"current-context"`
|
||
}
|
||
|
||
// Client wraps an HTTP client for K8s API calls
|
||
type Client struct {
|
||
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
|
||
func NewClient(kubeconfigYAML string) (*Client, error) {
|
||
var kcfg KubeConfig
|
||
if err := yaml.Unmarshal([]byte(kubeconfigYAML), &kcfg); err != nil {
|
||
return nil, fmt.Errorf("parse kubeconfig: %w", err)
|
||
}
|
||
|
||
// Find current context
|
||
ctxName := kcfg.CurrentContext
|
||
var clusterName, userName string
|
||
for _, ctx := range kcfg.Contexts {
|
||
if ctx.Name == ctxName {
|
||
clusterName = ctx.Context.Cluster
|
||
userName = ctx.Context.User
|
||
break
|
||
}
|
||
}
|
||
if clusterName == "" {
|
||
return nil, fmt.Errorf("current context %q not found", ctxName)
|
||
}
|
||
|
||
// Find cluster
|
||
var server string
|
||
var caData string
|
||
for _, cl := range kcfg.Clusters {
|
||
if cl.Name == clusterName {
|
||
server = cl.Cluster.Server
|
||
caData = cl.Cluster.CertificateAuthorityData
|
||
break
|
||
}
|
||
}
|
||
if server == "" {
|
||
return nil, fmt.Errorf("cluster %q not found", clusterName)
|
||
}
|
||
|
||
// Build TLS config
|
||
tlsCfg := &tls.Config{}
|
||
if caData != "" {
|
||
caBytes, err := base64.StdEncoding.DecodeString(caData)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode CA cert: %w", err)
|
||
}
|
||
caPool := x509.NewCertPool()
|
||
caPool.AppendCertsFromPEM(caBytes)
|
||
tlsCfg.RootCAs = caPool
|
||
} else {
|
||
tlsCfg.InsecureSkipVerify = true
|
||
}
|
||
|
||
// 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 != "" {
|
||
certBytes, _ := base64.StdEncoding.DecodeString(u.User.ClientCertificateData)
|
||
keyBytes, _ := base64.StdEncoding.DecodeString(u.User.ClientKeyData)
|
||
cert, err := tls.X509KeyPair(certBytes, keyBytes)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse client cert: %w", err)
|
||
}
|
||
tlsCfg.Certificates = []tls.Certificate{cert}
|
||
}
|
||
if u.User.Token != "" {
|
||
bearerToken = u.User.Token
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
httpClient := &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
Transport: &http.Transport{
|
||
TLSClientConfig: tlsCfg,
|
||
},
|
||
}
|
||
|
||
return &Client{
|
||
BaseURL: strings.TrimRight(server, "/"),
|
||
HTTPClient: httpClient,
|
||
KubeconfigYAML: kubeconfigYAML,
|
||
TLSConfig: tlsCfg,
|
||
BearerToken: bearerToken,
|
||
}, nil
|
||
}
|
||
|
||
// get performs a GET request to the K8s API
|
||
func (c *Client) get(path string, result interface{}) error {
|
||
url := c.BaseURL + path
|
||
resp, err := c.HTTPClient.Get(url)
|
||
if err != nil {
|
||
return fmt.Errorf("GET %s: %w", path, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return fmt.Errorf("read body: %w", err)
|
||
}
|
||
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
|
||
}
|
||
|
||
if err := json.Unmarshal(body, result); err != nil {
|
||
return fmt.Errorf("unmarshal: %w (body: %s)", err, string(body[:min(len(body), 100)]))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// getRaw performs a GET request returning raw bytes with custom Accept header
|
||
func (c *Client) getRaw(path string, accept string) ([]byte, error) {
|
||
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||
if err != nil { return nil, err }
|
||
req.Header.Set("Accept", accept)
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil { return nil, fmt.Errorf("GET %s: %w", path, err) }
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil { return nil, fmt.Errorf("read body: %w", err) }
|
||
if resp.StatusCode >= 400 {
|
||
return nil, fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
|
||
}
|
||
return body, nil
|
||
}
|
||
|
||
// putRaw performs a PUT request with raw body and custom Content-Type
|
||
func (c *Client) putRaw(path string, contentType string, data []byte) error {
|
||
req, err := http.NewRequest("PUT", c.BaseURL+path, strings.NewReader(string(data)))
|
||
if err != nil { return err }
|
||
req.Header.Set("Content-Type", contentType)
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil { return fmt.Errorf("PUT %s: %w", path, err) }
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 300)]))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// --- K8s API response types ---
|
||
|
||
// NodeList response
|
||
type NodeList struct {
|
||
Items []NodeItem `json:"items"`
|
||
}
|
||
|
||
type NodeItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Status NodeStatus `json:"status"`
|
||
Spec NodeSpec `json:"spec"`
|
||
}
|
||
|
||
type NodeSpec struct {
|
||
PodCIDR string `json:"podCIDR"`
|
||
}
|
||
|
||
type NodeStatus struct {
|
||
Capacity ResourceList `json:"capacity"`
|
||
Allocatable ResourceList `json:"allocatable"`
|
||
Conditions []NodeCondition `json:"conditions"`
|
||
NodeInfo NodeInfo `json:"nodeInfo"`
|
||
Addresses []NodeAddress `json:"addresses"`
|
||
}
|
||
|
||
type ResourceList map[string]string
|
||
|
||
type NodeCondition struct {
|
||
Type string `json:"type"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
type NodeInfo struct {
|
||
KubeletVersion string `json:"kubeletVersion"`
|
||
OSImage string `json:"osImage"`
|
||
KernelVersion string `json:"kernelVersion"`
|
||
ContainerRuntimeVersion string `json:"containerRuntimeVersion"`
|
||
}
|
||
|
||
type NodeAddress struct {
|
||
Type string `json:"type"`
|
||
Address string `json:"address"`
|
||
}
|
||
|
||
// PodList response
|
||
type PodList struct {
|
||
Items []PodItem `json:"items"`
|
||
}
|
||
|
||
type PodItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Status PodStatus `json:"status"`
|
||
Spec PodSpec `json:"spec"`
|
||
}
|
||
|
||
type PodSpec struct {
|
||
NodeName string `json:"nodeName"`
|
||
Containers []Container `json:"containers"`
|
||
}
|
||
|
||
type Container struct {
|
||
Name string `json:"name"`
|
||
Image string `json:"image"`
|
||
Resources ContainerRes `json:"resources"`
|
||
}
|
||
|
||
type ContainerRes struct {
|
||
Requests map[string]string `json:"requests"`
|
||
Limits map[string]string `json:"limits"`
|
||
}
|
||
|
||
type PodStatus struct {
|
||
Phase string `json:"phase"`
|
||
PodIP string `json:"podIP"`
|
||
ContainerStatuses []ContainerStatus `json:"containerStatuses"`
|
||
}
|
||
|
||
type ContainerStatus struct {
|
||
RestartCount int `json:"restartCount"`
|
||
Ready bool `json:"ready"`
|
||
}
|
||
|
||
// DeploymentList response
|
||
type DeploymentList struct {
|
||
Items []DeploymentItem `json:"items"`
|
||
}
|
||
|
||
type DeploymentItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec DeploymentSpec `json:"spec"`
|
||
Status DeploymentStatus `json:"status"`
|
||
}
|
||
|
||
type DeploymentSpec struct {
|
||
Replicas int `json:"replicas"`
|
||
Template DeploymentTemplate `json:"template"`
|
||
}
|
||
|
||
type DeploymentTemplate struct {
|
||
Spec PodSpec `json:"spec"`
|
||
}
|
||
|
||
type DeploymentStatus struct {
|
||
Replicas int `json:"replicas"`
|
||
AvailableReplicas int `json:"availableReplicas"`
|
||
ReadyReplicas int `json:"readyReplicas"`
|
||
}
|
||
|
||
// ServiceList response
|
||
type ServiceList struct {
|
||
Items []ServiceItem `json:"items"`
|
||
}
|
||
|
||
type ServiceItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec ServiceSpec `json:"spec"`
|
||
}
|
||
|
||
type ServiceSpec struct {
|
||
Type string `json:"type"`
|
||
ClusterIP string `json:"clusterIP"`
|
||
Ports []ServicePort `json:"ports"`
|
||
Selector map[string]string `json:"selector"`
|
||
}
|
||
|
||
type ServicePort struct {
|
||
Name string `json:"name"`
|
||
Port int `json:"port"`
|
||
TargetPort interface{} `json:"targetPort"`
|
||
NodePort int `json:"nodePort"`
|
||
Protocol string `json:"protocol"`
|
||
}
|
||
|
||
// IngressList response
|
||
type IngressList struct {
|
||
Items []IngressItem `json:"items"`
|
||
}
|
||
|
||
type IngressItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec IngressSpec `json:"spec"`
|
||
}
|
||
|
||
type IngressSpec struct {
|
||
TLS []IngressTLS `json:"tls"`
|
||
Rules []IngressRule `json:"rules"`
|
||
}
|
||
|
||
type IngressTLS struct {
|
||
Hosts []string `json:"hosts"`
|
||
}
|
||
|
||
type IngressRule struct {
|
||
Host string `json:"host"`
|
||
}
|
||
|
||
// ObjectMeta
|
||
type ObjectMeta struct {
|
||
Name string `json:"name"`
|
||
Namespace string `json:"namespace"`
|
||
Labels map[string]string `json:"labels"`
|
||
Annotations map[string]string `json:"annotations"`
|
||
CreationTimestamp string `json:"creationTimestamp"`
|
||
}
|
||
|
||
// SyncResult holds sync counts
|
||
type SyncResult struct {
|
||
Nodes int `json:"nodes"`
|
||
Pods int `json:"pods"`
|
||
Deployments int `json:"deployments"`
|
||
Services int `json:"services"`
|
||
Ingresses int `json:"ingresses"`
|
||
}
|
||
|
||
// GetServerVersion returns K8s server version
|
||
func (c *Client) GetServerVersion() (string, error) {
|
||
var result struct {
|
||
GitVersion string `json:"gitVersion"`
|
||
}
|
||
if err := c.get("/version", &result); err != nil {
|
||
return "", err
|
||
}
|
||
return result.GitVersion, nil
|
||
}
|
||
|
||
// ListNodes fetches all nodes
|
||
func (c *Client) ListNodes() (*NodeList, error) {
|
||
var result NodeList
|
||
if err := c.get("/api/v1/nodes", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ListPods fetches all pods across namespaces
|
||
func (c *Client) ListPods() (*PodList, error) {
|
||
var result PodList
|
||
if err := c.get("/api/v1/pods", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ListDeployments fetches all deployments across namespaces
|
||
func (c *Client) ListDeployments() (*DeploymentList, error) {
|
||
var result DeploymentList
|
||
if err := c.get("/apis/apps/v1/deployments", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ListServices fetches all services across namespaces
|
||
func (c *Client) ListServices() (*ServiceList, error) {
|
||
var result ServiceList
|
||
if err := c.get("/api/v1/services", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ListIngresses fetches all ingresses across namespaces
|
||
func (c *Client) ListIngresses() (*IngressList, error) {
|
||
var result IngressList
|
||
if err := c.get("/apis/networking.k8s.io/v1/ingresses", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// --- HTTP helpers (post/patch/put/delete) ---
|
||
|
||
func (c *Client) post(path string, body, result interface{}) error {
|
||
bodyBytes, err := json.Marshal(body)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal body: %w", err)
|
||
}
|
||
req, err := http.NewRequest("POST", c.BaseURL+path, strings.NewReader(string(bodyBytes)))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("POST %s: %w", path, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 300)]))
|
||
}
|
||
if result != nil {
|
||
return json.Unmarshal(respBody, result)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *Client) patch(path string, body, result interface{}) error {
|
||
bodyBytes, err := json.Marshal(body)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal body: %w", err)
|
||
}
|
||
req, err := http.NewRequest("PATCH", c.BaseURL+path, strings.NewReader(string(bodyBytes)))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "application/merge-patch+json")
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("PATCH %s: %w", path, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 300)]))
|
||
}
|
||
if result != nil {
|
||
return json.Unmarshal(respBody, result)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *Client) delete(path string) error {
|
||
req, err := http.NewRequest("DELETE", c.BaseURL+path, nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resp, err := c.HTTPClient.Do(req)
|
||
if err != nil {
|
||
return fmt.Errorf("DELETE %s: %w", path, err)
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 && resp.StatusCode != 404 {
|
||
respBody, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 300)]))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// --- Pod Logs ---
|
||
|
||
// GetPodLogs fetches logs for a pod/container
|
||
func (c *Client) GetPodLogs(namespace, podName, container string, tailLines int) (string, error) {
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/log?tailLines=%d", namespace, podName, tailLines)
|
||
if container != "" {
|
||
path += "&container=" + container
|
||
}
|
||
url := c.BaseURL + path
|
||
resp, err := c.HTTPClient.Get(url)
|
||
if err != nil {
|
||
return "", fmt.Errorf("GET logs: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if resp.StatusCode >= 400 {
|
||
return "", fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(body[:min(len(body), 300)]))
|
||
}
|
||
return string(body), nil
|
||
}
|
||
|
||
// GetPodLogsStream returns the raw streaming response body for follow=true
|
||
func (c *Client) GetPodLogsStream(namespace, podName, container string, tailLines int) (io.ReadCloser, error) {
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/log?follow=true&tailLines=%d", namespace, podName, tailLines)
|
||
if container != "" {
|
||
path += "&container=" + container
|
||
}
|
||
url := c.BaseURL + path
|
||
resp, err := c.HTTPClient.Get(url)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("GET logs stream: %w", err)
|
||
}
|
||
if resp.StatusCode >= 400 {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
return nil, fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(body[:min(len(body), 300)]))
|
||
}
|
||
return resp.Body, nil
|
||
}
|
||
|
||
// --- Scale ---
|
||
|
||
// ScaleDeployment patches the replicas count for a deployment
|
||
func (c *Client) ScaleDeployment(namespace, name string, replicas int) error {
|
||
body := map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"replicas": replicas,
|
||
},
|
||
}
|
||
path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s/scale", namespace, name)
|
||
return c.patch(path, body, nil)
|
||
}
|
||
|
||
// --- Deployment Image Update (CI) ---
|
||
|
||
// UpdateDeploymentImage patches the container image of a deployment
|
||
func (c *Client) UpdateDeploymentImage(namespace, name, containerName, image string) error {
|
||
body := map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"template": map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"containers": []map[string]interface{}{
|
||
{"name": containerName, "image": image},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", namespace, name)
|
||
return c.patch(path, body, nil)
|
||
}
|
||
|
||
// RestartDeployment triggers a rolling restart by annotating the pod template
|
||
func (c *Client) RestartDeployment(namespace, name string) error {
|
||
body := map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"template": map[string]interface{}{
|
||
"metadata": map[string]interface{}{
|
||
"annotations": map[string]string{
|
||
"kubectl.kubernetes.io/restartedAt": time.Now().UTC().Format(time.RFC3339),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", namespace, name)
|
||
return c.patch(path, body, nil)
|
||
}
|
||
|
||
// --- Exec (K8s WebSocket channel protocol) ---
|
||
|
||
// ExecResult holds the output of a pod exec command
|
||
type ExecResult struct {
|
||
Stdout string `json:"stdout"`
|
||
Stderr string `json:"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) {
|
||
// 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://")
|
||
}
|
||
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 != "" {
|
||
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)
|
||
}
|
||
|
||
conn, _, err := dialer.Dial(execURL, header)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("K8s exec WS dial: %w", err)
|
||
}
|
||
defer conn.Close()
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
return &ExecResult{
|
||
Stdout: stdout.String(),
|
||
Stderr: stderr.String(),
|
||
}, nil
|
||
}
|
||
|
||
// --- HPA ---
|
||
|
||
// HPAList response
|
||
type HPAList struct {
|
||
Items []HPAItem `json:"items"`
|
||
}
|
||
|
||
// HPAItem represents a HorizontalPodAutoscaler
|
||
type HPAItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec HPASpec `json:"spec"`
|
||
Status HPAStatus `json:"status"`
|
||
}
|
||
|
||
// HPASpec defines the HPA spec
|
||
type HPASpec struct {
|
||
ScaleTargetRef ScaleTargetRef `json:"scaleTargetRef"`
|
||
MinReplicas int `json:"minReplicas"`
|
||
MaxReplicas int `json:"maxReplicas"`
|
||
Metrics []HPAMetric `json:"metrics"`
|
||
}
|
||
|
||
// ScaleTargetRef references the target resource to scale
|
||
type ScaleTargetRef struct {
|
||
APIVersion string `json:"apiVersion"`
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// HPAMetric defines a metric for HPA
|
||
type HPAMetric struct {
|
||
Type string `json:"type"`
|
||
Resource *struct {
|
||
Name string `json:"name"`
|
||
Target struct {
|
||
Type string `json:"type"`
|
||
AverageUtilization int `json:"averageUtilization"`
|
||
} `json:"target"`
|
||
} `json:"resource,omitempty"`
|
||
}
|
||
|
||
// HPAStatus holds the current status of the HPA
|
||
type HPAStatus struct {
|
||
CurrentReplicas int `json:"currentReplicas"`
|
||
DesiredReplicas int `json:"desiredReplicas"`
|
||
}
|
||
|
||
// ListHPAs fetches all HPAs across namespaces
|
||
func (c *Client) ListHPAs() (*HPAList, error) {
|
||
var result HPAList
|
||
if err := c.get("/apis/autoscaling/v2/horizontalpodautoscalers", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// CreateHPARequest is the payload for creating an HPA
|
||
type CreateHPARequest struct {
|
||
Name string `json:"name"`
|
||
Namespace string `json:"namespace"`
|
||
TargetKind string `json:"targetKind"` // Deployment, StatefulSet
|
||
TargetName string `json:"targetName"`
|
||
MinReplicas int `json:"minReplicas"`
|
||
MaxReplicas int `json:"maxReplicas"`
|
||
CPUPercent int `json:"cpuPercent"` // target average CPU utilization
|
||
MemPercent int `json:"memPercent"` // target average memory utilization (0 = disabled)
|
||
}
|
||
|
||
// CreateHPA creates a new HPA
|
||
func (c *Client) CreateHPA(req CreateHPARequest) (*HPAItem, error) {
|
||
metrics := []HPAMetric{}
|
||
if req.CPUPercent > 0 {
|
||
metrics = append(metrics, HPAMetric{
|
||
Type: "Resource",
|
||
Resource: &struct {
|
||
Name string `json:"name"`
|
||
Target struct {
|
||
Type string `json:"type"`
|
||
AverageUtilization int `json:"averageUtilization"`
|
||
} `json:"target"`
|
||
}{
|
||
Name: "cpu",
|
||
Target: struct {
|
||
Type string `json:"type"`
|
||
AverageUtilization int `json:"averageUtilization"`
|
||
}{Type: "Utilization", AverageUtilization: req.CPUPercent},
|
||
},
|
||
})
|
||
}
|
||
if req.MemPercent > 0 {
|
||
metrics = append(metrics, HPAMetric{
|
||
Type: "Resource",
|
||
Resource: &struct {
|
||
Name string `json:"name"`
|
||
Target struct {
|
||
Type string `json:"type"`
|
||
AverageUtilization int `json:"averageUtilization"`
|
||
} `json:"target"`
|
||
}{
|
||
Name: "memory",
|
||
Target: struct {
|
||
Type string `json:"type"`
|
||
AverageUtilization int `json:"averageUtilization"`
|
||
}{Type: "Utilization", AverageUtilization: req.MemPercent},
|
||
},
|
||
})
|
||
}
|
||
|
||
body := map[string]interface{}{
|
||
"apiVersion": "autoscaling/v2",
|
||
"kind": "HorizontalPodAutoscaler",
|
||
"metadata": map[string]interface{}{
|
||
"name": req.Name,
|
||
"namespace": req.Namespace,
|
||
},
|
||
"spec": map[string]interface{}{
|
||
"scaleTargetRef": map[string]interface{}{
|
||
"apiVersion": "apps/v1",
|
||
"kind": req.TargetKind,
|
||
"name": req.TargetName,
|
||
},
|
||
"minReplicas": req.MinReplicas,
|
||
"maxReplicas": req.MaxReplicas,
|
||
"metrics": metrics,
|
||
},
|
||
}
|
||
|
||
var result HPAItem
|
||
path := fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers", req.Namespace)
|
||
if err := c.post(path, body, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// DeleteHPA deletes an HPA
|
||
func (c *Client) DeleteHPA(namespace, name string) error {
|
||
path := fmt.Sprintf("/apis/autoscaling/v2/namespaces/%s/horizontalpodautoscalers/%s", namespace, name)
|
||
return c.delete(path)
|
||
}
|
||
|
||
// --- CronJob ---
|
||
|
||
// CronJobList response
|
||
type CronJobList struct {
|
||
Items []CronJobItem `json:"items"`
|
||
}
|
||
|
||
// CronJobItem represents a CronJob
|
||
type CronJobItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec CronJobSpec `json:"spec"`
|
||
Status CronJobStatus `json:"status"`
|
||
}
|
||
|
||
// CronJobSpec defines the CronJob spec
|
||
type CronJobSpec struct {
|
||
Schedule string `json:"schedule"`
|
||
Suspend *bool `json:"suspend"`
|
||
ConcurrencyPolicy string `json:"concurrencyPolicy"`
|
||
JobTemplate struct {
|
||
Spec struct {
|
||
Template struct {
|
||
Spec struct {
|
||
Containers []Container `json:"containers"`
|
||
} `json:"spec"`
|
||
} `json:"template"`
|
||
} `json:"spec"`
|
||
} `json:"jobTemplate"`
|
||
}
|
||
|
||
// CronJobStatus holds the status
|
||
type CronJobStatus struct {
|
||
Active []interface{} `json:"active"`
|
||
LastScheduleTime string `json:"lastScheduleTime"`
|
||
}
|
||
|
||
// ListCronJobs fetches all CronJobs across namespaces
|
||
func (c *Client) ListCronJobs() (*CronJobList, error) {
|
||
var result CronJobList
|
||
if err := c.get("/apis/batch/v1/cronjobs", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// CreateCronJobRequest is the payload for creating a CronJob
|
||
type CreateCronJobRequest struct {
|
||
Name string `json:"name"`
|
||
Namespace string `json:"namespace"`
|
||
Schedule string `json:"schedule"` // cron expression
|
||
Image string `json:"image"`
|
||
Command []string `json:"command"`
|
||
}
|
||
|
||
// CreateCronJob creates a new CronJob
|
||
func (c *Client) CreateCronJob(req CreateCronJobRequest) (*CronJobItem, error) {
|
||
suspend := false
|
||
body := map[string]interface{}{
|
||
"apiVersion": "batch/v1",
|
||
"kind": "CronJob",
|
||
"metadata": map[string]interface{}{
|
||
"name": req.Name,
|
||
"namespace": req.Namespace,
|
||
},
|
||
"spec": map[string]interface{}{
|
||
"schedule": req.Schedule,
|
||
"suspend": &suspend,
|
||
"concurrencyPolicy": "Forbid",
|
||
"jobTemplate": map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"template": map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"containers": []map[string]interface{}{
|
||
{
|
||
"name": req.Name,
|
||
"image": req.Image,
|
||
"command": req.Command,
|
||
},
|
||
},
|
||
"restartPolicy": "OnFailure",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
var result CronJobItem
|
||
path := fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs", req.Namespace)
|
||
if err := c.post(path, body, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// SuspendCronJob suspends or resumes a CronJob
|
||
func (c *Client) SuspendCronJob(namespace, name string, suspend bool) error {
|
||
body := map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"suspend": &suspend,
|
||
},
|
||
}
|
||
path := fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", namespace, name)
|
||
return c.patch(path, body, nil)
|
||
}
|
||
|
||
// DeleteCronJob deletes a CronJob
|
||
func (c *Client) DeleteCronJob(namespace, name string) error {
|
||
path := fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", namespace, name)
|
||
return c.delete(path)
|
||
}
|
||
|
||
// TriggerCronJob manually creates a Job from a CronJob
|
||
func (c *Client) TriggerCronJob(namespace, name string) error {
|
||
// Patch the CronJob with an annotation to trigger reconciliation.
|
||
triggerTime := time.Now().UTC().Format(time.RFC3339)
|
||
triggerBody := map[string]interface{}{
|
||
"metadata": map[string]interface{}{
|
||
"annotations": map[string]string{
|
||
"aiops.io/manual-trigger": triggerTime,
|
||
},
|
||
},
|
||
}
|
||
path := fmt.Sprintf("/apis/batch/v1/namespaces/%s/cronjobs/%s", namespace, name)
|
||
return c.patch(path, triggerBody, nil)
|
||
}
|
||
|
||
// --- Namespace ---
|
||
|
||
// ListNamespaces fetches all namespaces
|
||
func (c *Client) ListNamespaces() ([]string, error) {
|
||
var result struct {
|
||
Items []struct {
|
||
Metadata struct {
|
||
Name string `json:"name"`
|
||
} `json:"metadata"`
|
||
} `json:"items"`
|
||
}
|
||
if err := c.get("/api/v1/namespaces", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
names := make([]string, len(result.Items))
|
||
for i, ns := range result.Items {
|
||
names[i] = ns.Metadata.Name
|
||
}
|
||
return names, nil
|
||
}
|
||
|
||
// --- ConfigMap ---
|
||
|
||
// ConfigMapList response
|
||
type ConfigMapList struct {
|
||
Items []ConfigMapItem `json:"items"`
|
||
}
|
||
|
||
// ConfigMapItem represents a ConfigMap
|
||
type ConfigMapItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Data map[string]string `json:"data"`
|
||
}
|
||
|
||
// ListConfigMaps fetches ConfigMaps in a namespace
|
||
func (c *Client) ListConfigMaps(namespace string) (*ConfigMapList, error) {
|
||
var result ConfigMapList
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/configmaps", namespace)
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// --- Secret ---
|
||
|
||
// SecretList response
|
||
type SecretList struct {
|
||
Items []SecretItem `json:"items"`
|
||
}
|
||
|
||
// SecretItem represents a Secret (return keys only, not values)
|
||
type SecretItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Type string `json:"type"`
|
||
DataKeys []string `json:"-"`
|
||
}
|
||
|
||
// ListSecrets fetches Secrets in a namespace (names+types only, no data)
|
||
func (c *Client) ListSecrets(namespace string) (*SecretList, error) {
|
||
var result struct {
|
||
Items []struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Type string `json:"type"`
|
||
Data map[string]string `json:"data"`
|
||
} `json:"items"`
|
||
}
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/secrets", namespace)
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]SecretItem, len(result.Items))
|
||
for i, item := range result.Items {
|
||
keys := make([]string, 0, len(item.Data))
|
||
for k := range item.Data {
|
||
keys = append(keys, k)
|
||
}
|
||
items[i] = SecretItem{
|
||
Metadata: item.Metadata,
|
||
Type: item.Type,
|
||
DataKeys: keys,
|
||
}
|
||
}
|
||
return &SecretList{Items: items}, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// PersistentVolumeClaim (存储)
|
||
// ============================================================================
|
||
|
||
type PVCList struct {
|
||
Items []PVCItem `json:"items"`
|
||
}
|
||
|
||
type PVCItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec PVCSpec `json:"spec"`
|
||
Status struct {
|
||
Phase string `json:"phase"`
|
||
Capacity map[string]string `json:"capacity"`
|
||
} `json:"status"`
|
||
}
|
||
|
||
type PVCSpec struct {
|
||
AccessModes []string `json:"accessModes"`
|
||
Resources struct {
|
||
Requests map[string]string `json:"requests"`
|
||
} `json:"resources"`
|
||
StorageClassName string `json:"storageClassName"`
|
||
}
|
||
|
||
// ListPVCs fetches PersistentVolumeClaims in a namespace
|
||
func (c *Client) ListPVCs(namespace string) (*PVCList, error) {
|
||
var result PVCList
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims", namespace)
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// DeletePVC deletes a PersistentVolumeClaim
|
||
func (c *Client) DeletePVC(namespace, name string) error {
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", namespace, name)
|
||
return c.delete(path)
|
||
}
|
||
|
||
// ============================================================================
|
||
// StatefulSet
|
||
// ============================================================================
|
||
|
||
type StatefulSetList struct {
|
||
Items []StatefulSetItem `json:"items"`
|
||
}
|
||
|
||
type StatefulSetItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec StatefulSetSpec `json:"spec"`
|
||
Status struct {
|
||
Replicas int `json:"replicas"`
|
||
ReadyReplicas int `json:"readyReplicas"`
|
||
} `json:"status"`
|
||
}
|
||
|
||
type StatefulSetSpec struct {
|
||
Replicas int `json:"replicas"`
|
||
Template struct {
|
||
Spec struct {
|
||
Containers []struct {
|
||
Name string `json:"name"`
|
||
Image string `json:"image"`
|
||
} `json:"containers"`
|
||
} `json:"spec"`
|
||
} `json:"template"`
|
||
}
|
||
|
||
// ListStatefulSets fetches StatefulSets across all namespaces
|
||
func (c *Client) ListStatefulSets() (*StatefulSetList, error) {
|
||
var result StatefulSetList
|
||
if err := c.get("/apis/apps/v1/statefulsets", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// DaemonSet
|
||
// ============================================================================
|
||
|
||
type DaemonSetList struct {
|
||
Items []DaemonSetItem `json:"items"`
|
||
}
|
||
|
||
type DaemonSetItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec DaemonSetSpec `json:"spec"`
|
||
Status struct {
|
||
DesiredNumberScheduled int `json:"desiredNumberScheduled"`
|
||
CurrentNumberScheduled int `json:"currentNumberScheduled"`
|
||
NumberReady int `json:"numberReady"`
|
||
} `json:"status"`
|
||
}
|
||
|
||
type DaemonSetSpec struct {
|
||
Template struct {
|
||
Spec struct {
|
||
Containers []struct {
|
||
Name string `json:"name"`
|
||
Image string `json:"image"`
|
||
} `json:"containers"`
|
||
} `json:"spec"`
|
||
} `json:"template"`
|
||
}
|
||
|
||
// ListDaemonSets fetches DaemonSets across all namespaces
|
||
func (c *Client) ListDaemonSets() (*DaemonSetList, error) {
|
||
var result DaemonSetList
|
||
if err := c.get("/apis/apps/v1/daemonsets", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// NodeMetrics (metrics-server, optional)
|
||
// ============================================================================
|
||
|
||
type NodeMetricsList struct {
|
||
Items []NodeMetricsItem `json:"items"`
|
||
}
|
||
|
||
type NodeMetricsItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Usage map[string]string `json:"usage"` // cpu, memory
|
||
}
|
||
|
||
// GetNodeMetrics fetches node CPU/memory usage from metrics-server
|
||
func (c *Client) GetNodeMetrics() (*NodeMetricsList, error) {
|
||
var result NodeMetricsList
|
||
if err := c.get("/apis/metrics.k8s.io/v1beta1/nodes", &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// PodMetrics (metrics-server, per-container CPU/Memory)
|
||
// ============================================================================
|
||
|
||
type PodMetricsList struct {
|
||
Items []PodMetricsItem `json:"items"`
|
||
}
|
||
|
||
type PodMetricsItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Containers []ContainerMetrics `json:"containers"`
|
||
}
|
||
|
||
type ContainerMetrics struct {
|
||
Name string `json:"name"`
|
||
Usage map[string]string `json:"usage"` // cpu, memory
|
||
}
|
||
|
||
// ListPodMetrics fetches pod CPU/memory usage, optionally filtered by namespace
|
||
func (c *Client) ListPodMetrics(namespace string) (*PodMetricsList, error) {
|
||
var result PodMetricsList
|
||
path := "/apis/metrics.k8s.io/v1beta1/pods"
|
||
if namespace != "" {
|
||
path = fmt.Sprintf("/apis/metrics.k8s.io/v1beta1/namespaces/%s/pods", namespace)
|
||
}
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// Events
|
||
// ============================================================================
|
||
|
||
type EventList struct {
|
||
Items []EventItem `json:"items"`
|
||
}
|
||
|
||
type EventItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
InvolvedObject InvolvedObject `json:"involvedObject"`
|
||
Type string `json:"type"`
|
||
Reason string `json:"reason"`
|
||
Message string `json:"message"`
|
||
Count int32 `json:"count"`
|
||
FirstTimestamp time.Time `json:"firstTimestamp"`
|
||
LastTimestamp time.Time `json:"lastTimestamp"`
|
||
EventTime time.Time `json:"eventTime"`
|
||
}
|
||
|
||
type InvolvedObject struct {
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
Namespace string `json:"namespace"`
|
||
}
|
||
|
||
// ListEvents fetches cluster events, optionally filtered by namespace
|
||
func (c *Client) ListEvents(namespace string) (*EventList, error) {
|
||
var result EventList
|
||
path := "/api/v1/events"
|
||
if namespace != "" {
|
||
path = fmt.Sprintf("/api/v1/namespaces/%s/events", namespace)
|
||
}
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return &result, nil
|
||
}
|
||
|
||
// ============================================================================
|
||
// YAML Apply (Dynamic resource apply)
|
||
// ============================================================================
|
||
|
||
// ApplyYAML applies a raw YAML manifest using the K8s discovery API
|
||
func (c *Client) ApplyYAML(yamlContent string) (map[string]interface{}, error) {
|
||
return c.applyRaw(yamlContent)
|
||
}
|
||
|
||
// GetResourceYAML fetches raw YAML for any K8s resource by API path
|
||
func (c *Client) GetResourceYAML(apiPath string) ([]byte, error) {
|
||
return c.getRaw(apiPath, "application/yaml")
|
||
}
|
||
|
||
// UpdateResourceYAML applies raw YAML to update a K8s resource
|
||
func (c *Client) UpdateResourceYAML(apiPath string, yamlData []byte) error {
|
||
return c.putRaw(apiPath, "application/yaml", yamlData)
|
||
}
|
||
|
||
// applyRaw parses YAML and applies it via create-or-update pattern
|
||
func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) {
|
||
// Parse YAML to extract resource metadata
|
||
var obj struct {
|
||
APIVersion string `yaml:"apiVersion"`
|
||
Kind string `yaml:"kind"`
|
||
Metadata struct {
|
||
Name string `yaml:"name"`
|
||
Namespace string `yaml:"namespace"`
|
||
} `yaml:"metadata"`
|
||
}
|
||
if err := yaml.Unmarshal([]byte(yamlContent), &obj); err != nil {
|
||
return nil, fmt.Errorf("YAML 解析失败: %w", err)
|
||
}
|
||
if obj.Kind == "" || obj.Metadata.Name == "" {
|
||
return nil, fmt.Errorf("YAML 中缺少 kind 或 name")
|
||
}
|
||
|
||
// Build API path from kind
|
||
apiPath, isNamespaced := c.resourceAPIPath(obj.APIVersion, obj.Kind, obj.Metadata.Namespace, obj.Metadata.Name)
|
||
if apiPath == "" {
|
||
return nil, fmt.Errorf("不支持的资源类型: %s/%s", obj.APIVersion, obj.Kind)
|
||
}
|
||
|
||
// Try GET first — if 200, it exists, do PUT. If 404, do POST.
|
||
parentPath := apiPath[:strings.LastIndex(apiPath, "/")] // without /name suffix
|
||
_, getErr := c.getRaw(apiPath, "application/yaml")
|
||
if getErr == nil {
|
||
// Resource exists → PUT update
|
||
if err := c.putRaw(apiPath, "application/yaml", []byte(yamlContent)); err != nil {
|
||
return nil, fmt.Errorf("更新资源失败: %w", err)
|
||
}
|
||
return map[string]interface{}{"action": "updated", "path": apiPath}, nil
|
||
}
|
||
|
||
// Resource doesn't exist → POST create (for namespaced) or POST to parent
|
||
var result map[string]interface{}
|
||
if isNamespaced && obj.Metadata.Namespace == "" {
|
||
obj.Metadata.Namespace = "default"
|
||
parentPath = strings.Replace(parentPath, "/namespaces//", "/namespaces/default/", 1)
|
||
}
|
||
if err := c.post(parentPath, []byte(yamlContent), &result); err != nil {
|
||
return nil, fmt.Errorf("创建资源失败: %w", err)
|
||
}
|
||
return map[string]interface{}{"action": "created", "path": parentPath, "result": result}, nil
|
||
}
|
||
|
||
// resourceAPIPath returns the K8s REST API path for a given resource kind
|
||
func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (string, bool) {
|
||
resource := strings.ToLower(kind) + "s"
|
||
switch kind {
|
||
case "Endpoints": resource = "endpoints"
|
||
case "PodMetrics": resource = "pods"
|
||
}
|
||
|
||
var groupPrefix string
|
||
if apiVersion != "" && apiVersion != "v1" {
|
||
parts := strings.SplitN(apiVersion, "/", 2)
|
||
if len(parts) == 2 {
|
||
groupPrefix = "/apis/" + parts[0] + "/" + parts[1]
|
||
} else {
|
||
groupPrefix = "/apis/" + parts[0]
|
||
}
|
||
}
|
||
|
||
isNamespaced := true
|
||
switch kind {
|
||
case "Namespace", "Node", "ClusterRole", "ClusterRoleBinding", "PersistentVolume":
|
||
isNamespaced = false
|
||
}
|
||
|
||
if !isNamespaced {
|
||
if groupPrefix != "" {
|
||
return groupPrefix + "/" + resource + "/" + name, false
|
||
}
|
||
return "/api/v1/" + resource + "/" + name, false
|
||
}
|
||
|
||
ns := namespace
|
||
if ns == "" { ns = "default" }
|
||
|
||
if groupPrefix != "" {
|
||
switch kind {
|
||
case "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet":
|
||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||
case "HorizontalPodAutoscaler":
|
||
return groupPrefix + "/namespaces/" + ns + "/" + strings.ToLower(kind) + "s/" + name, true
|
||
case "Ingress":
|
||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||
default:
|
||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||
}
|
||
}
|
||
|
||
// Core v1 API
|
||
coreResources := map[string]string{
|
||
"Pod": "pods", "Service": "services", "ConfigMap": "configmaps",
|
||
"Secret": "secrets", "PersistentVolumeClaim": "persistentvolumeclaims",
|
||
}
|
||
if r, ok := coreResources[kind]; ok {
|
||
return "/api/v1/namespaces/" + ns + "/" + r + "/" + name, true
|
||
}
|
||
return "/api/v1/namespaces/" + ns + "/" + resource + "/" + name, true
|
||
}
|
||
|
||
// ============================================================================
|
||
// ReplicaSet — 用于 Deployment 回滚 / 历史版本查询 // Logan.Li
|
||
// ============================================================================
|
||
|
||
// ReplicaSetList response
|
||
type ReplicaSetList struct {
|
||
Items []ReplicaSetItem `json:"items"`
|
||
}
|
||
|
||
// ReplicaSetItem 表示一个 ReplicaSet(含完整 spec/status/metadata,用于回滚取 template)
|
||
type ReplicaSetItem struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec ReplicaSetSpec `json:"spec"`
|
||
Status ReplicaSetStatus `json:"status"`
|
||
}
|
||
|
||
// ReplicaSetSpec 与 DeploymentSpec 结构一致,关键是 template
|
||
type ReplicaSetSpec struct {
|
||
Replicas int `json:"replicas"`
|
||
Template ReplicaSetTemplate `json:"template"`
|
||
}
|
||
|
||
// ReplicaSetTemplate pod 模板
|
||
type ReplicaSetTemplate struct {
|
||
Metadata ObjectMeta `json:"metadata"`
|
||
Spec PodSpec `json:"spec"`
|
||
}
|
||
|
||
// ReplicaSetStatus 副本状态
|
||
type ReplicaSetStatus struct {
|
||
Replicas int `json:"replicas"`
|
||
AvailableReplicas int `json:"availableReplicas"`
|
||
ReadyReplicas int `json:"readyReplicas"`
|
||
}
|
||
|
||
// OwnerReference 资源归属引用
|
||
type OwnerReference struct {
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
// ReplicaSetOwnerMeta 在 ListReplicaSets 时额外携带 ownerReferences
|
||
type ReplicaSetOwnerMeta struct {
|
||
Metadata struct {
|
||
Name string `json:"name"`
|
||
Namespace string `json:"namespace"`
|
||
Labels map[string]string `json:"labels"`
|
||
Annotations map[string]string `json:"annotations"`
|
||
CreationTimestamp string `json:"creationTimestamp"`
|
||
OwnerReferences []OwnerReference `json:"ownerReferences"`
|
||
} `json:"metadata"`
|
||
Spec struct {
|
||
Replicas int `json:"replicas"`
|
||
Template ReplicaSetTemplate `json:"template"`
|
||
} `json:"spec"`
|
||
Status ReplicaSetStatus `json:"status"`
|
||
}
|
||
|
||
// ListReplicaSetsRaw 列出 namespace 下的 ReplicaSet(含 ownerReferences 原始字段)
|
||
func (c *Client) ListReplicaSetsRaw(namespace string) ([]ReplicaSetOwnerMeta, error) {
|
||
var result struct {
|
||
Items []ReplicaSetOwnerMeta `json:"items"`
|
||
}
|
||
path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets", namespace)
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return result.Items, nil
|
||
}
|
||
|
||
// GetReplicaSet 获取单个 ReplicaSet 的完整 JSON(map 形式,便于取 template 直接 patch)
|
||
func (c *Client) GetReplicaSet(namespace, name string) (map[string]interface{}, error) {
|
||
path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/replicasets/%s", namespace, name)
|
||
var result map[string]interface{}
|
||
if err := c.get(path, &result); err != nil {
|
||
return nil, err
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// PatchDeploymentSpecTemplate 用旧 ReplicaSet 的 template 回滚 Deployment
|
||
// 通过 merge-patch 将 deployment.spec.template 整体替换为 rsTemplate
|
||
func (c *Client) PatchDeploymentSpecTemplate(namespace, deploymentName string, rsTemplate map[string]interface{}) error {
|
||
body := map[string]interface{}{
|
||
"spec": map[string]interface{}{
|
||
"template": rsTemplate,
|
||
},
|
||
}
|
||
path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", namespace, deploymentName)
|
||
return c.patch(path, body, nil)
|
||
}
|
||
|
||
// ============================================================================
|
||
// Pod 删除 / 高级日志参数 // Logan.Li
|
||
// ============================================================================
|
||
|
||
// DeletePod 删除指定 namespace 下的 Pod
|
||
func (c *Client) DeletePod(namespace, name string) error {
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s", namespace, name)
|
||
return c.delete(path)
|
||
}
|
||
|
||
// LogOptions 日志查询高级参数(container/sinceTime/previous/tailLines)
|
||
type LogOptions struct {
|
||
Container string
|
||
SinceTime string // RFC3339 格式,仅返回此时间之后的日志
|
||
Previous bool // 返回已终止容器(previous instance)的日志
|
||
TailLines int // 末尾 N 行,0 表示不限制
|
||
}
|
||
|
||
// buildLogPath 构造 K8s pod log API 的完整查询路径
|
||
func (c *Client) buildLogPath(namespace, podName string, opts LogOptions, follow bool) string {
|
||
q := []string{}
|
||
if opts.Container != "" {
|
||
q = append(q, "container="+opts.Container)
|
||
}
|
||
if opts.TailLines > 0 {
|
||
q = append(q, fmt.Sprintf("tailLines=%d", opts.TailLines))
|
||
}
|
||
if opts.SinceTime != "" {
|
||
q = append(q, "sinceTime="+opts.SinceTime)
|
||
}
|
||
if opts.Previous {
|
||
q = append(q, "previous=true")
|
||
}
|
||
if follow {
|
||
q = append(q, "follow=true")
|
||
}
|
||
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/log", namespace, podName)
|
||
if len(q) > 0 {
|
||
path += "?" + strings.Join(q, "&")
|
||
}
|
||
return path
|
||
}
|
||
|
||
// GetPodLogsWithOptions 支持高级参数的日志查询(非流式)
|
||
func (c *Client) GetPodLogsWithOptions(namespace, podName string, opts LogOptions) (string, error) {
|
||
url := c.BaseURL + c.buildLogPath(namespace, podName, opts, false)
|
||
resp, err := c.HTTPClient.Get(url)
|
||
if err != nil {
|
||
return "", fmt.Errorf("GET logs: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if resp.StatusCode >= 400 {
|
||
return "", fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(body[:min(len(body), 300)]))
|
||
}
|
||
return string(body), nil
|
||
}
|
||
|
||
// GetPodLogsStreamWithOptions 支持高级参数的日志流式查询(follow=true)
|
||
func (c *Client) GetPodLogsStreamWithOptions(namespace, podName string, opts LogOptions) (io.ReadCloser, error) {
|
||
url := c.BaseURL + c.buildLogPath(namespace, podName, opts, true)
|
||
resp, err := c.HTTPClient.Get(url)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("GET logs stream: %w", err)
|
||
}
|
||
if resp.StatusCode >= 400 {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
return nil, fmt.Errorf("K8s API %d: %s", resp.StatusCode, string(body[:min(len(body), 300)]))
|
||
}
|
||
return resp.Body, nil
|
||
}
|