feat(k8s): 完整 K8s 管理能力 — 日志/exec/扩缩容/HPA/CronJob/CI webhook
新增功能: - Pod 日志查看 (GET logs, 支持 tail/container 参数) - Pod exec 命令执行 (文件浏览 ls/cat/stat) - Deployment 副本数加减 (scale PATCH) - Deployment 滚动重启 (restart annotation) - Deployment CI webhook (update-image, 滚动更新镜像) - HPA 管理: 列表/创建(CPU+内存指标)/删除 - CronJob 管理: 列表/创建/暂停恢复/手动触发/删除 - Namespace 列表 K8s Client 扩展 (纯标准库, 零外部依赖): - 新增 post/patch/delete HTTP 方法 - GetPodLogs/ScaleDeployment/UpdateDeploymentImage/RestartDeployment - ExecInPod/ListHPAs/CreateHPA/DeleteHPA - ListCronJobs/CreateCronJob/SuspendCronJob/DeleteCronJob/TriggerCronJob - ListNamespaces 已知限制: Exec SPDY 交互终端需引入 remotecommand 包
This commit is contained in:
1 parent
d3c3fb546d
commit
33e58ce25a
3 files changed
+884
No files matched your search
@@ -730,6 +730,380 @@ func (h *K8sHandler) SyncCluster(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pod Logs
|
||||
// ============================================================================
|
||||
|
||||
// GetPodLogs 获取 Pod 日志
|
||||
func (h *K8sHandler) GetPodLogs(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
podName := c.Param("podName")
|
||||
container := c.Query("container")
|
||||
tailLines, _ := strconv.Atoi(c.DefaultQuery("tail", "200"))
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := client.GetPodLogs(namespace, podName, container, tailLines)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "获取日志失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{"logs": logs, "pod": podName, "namespace": namespace}})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exec / File Browser
|
||||
// ============================================================================
|
||||
|
||||
// ExecInPod 在 Pod 中执行命令 (文件浏览 / 单次命令)
|
||||
func (h *K8sHandler) ExecInPod(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
podName := c.Param("podName")
|
||||
|
||||
var req struct {
|
||||
Command []string `json:"command"`
|
||||
Container string `json:"container"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
|
||||
return
|
||||
}
|
||||
if len(req.Command) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "command 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := client.ExecInPod(namespace, podName, req.Container, req.Command)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "执行失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: result})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Scale (副本数加减)
|
||||
// ============================================================================
|
||||
|
||||
// ScaleDeployment 缩放 Deployment 副本数
|
||||
func (h *K8sHandler) ScaleDeployment(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
var req struct {
|
||||
Replicas int `json:"replicas"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Replicas < 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "副本数不能为负数"})
|
||||
return
|
||||
}
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.ScaleDeployment(namespace, name, req.Replicas); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "扩缩容失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("已设置为 %d 副本", req.Replicas)})
|
||||
}
|
||||
|
||||
// RestartDeployment 重启 Deployment (滚动重启)
|
||||
func (h *K8sHandler) RestartDeployment(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.RestartDeployment(namespace, name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "重启失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "重启指令已下发"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HPA 管理
|
||||
// ============================================================================
|
||||
|
||||
// ListHPAs 获取 HPA 列表
|
||||
func (h *K8sHandler) ListHPAs(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hpas, err := client.ListHPAs()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: hpas.Items})
|
||||
}
|
||||
|
||||
// CreateHPA 创建 HPA
|
||||
func (h *K8sHandler) CreateHPA(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
|
||||
var req k8sclient.CreateHPARequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.Namespace == "" || req.TargetName == "" {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "name/namespace/targetName 必填"})
|
||||
return
|
||||
}
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hpa, err := client.CreateHPA(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建 HPA 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "HPA 创建成功", Data: hpa})
|
||||
}
|
||||
|
||||
// DeleteHPA 删除 HPA
|
||||
func (h *K8sHandler) DeleteHPA(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.DeleteHPA(namespace, name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除 HPA 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "HPA 已删除"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CronJob 管理
|
||||
// ============================================================================
|
||||
|
||||
// ListCronJobs 获取 CronJob 列表
|
||||
func (h *K8sHandler) ListCronJobs(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
cronjobs, err := client.ListCronJobs()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: cronjobs.Items})
|
||||
}
|
||||
|
||||
// CreateCronJob 创建 CronJob
|
||||
func (h *K8sHandler) CreateCronJob(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
|
||||
var req k8sclient.CreateCronJobRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
cj, err := client.CreateCronJob(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建 CronJob 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 创建成功", Data: cj})
|
||||
}
|
||||
|
||||
// SuspendCronJob 暂停/恢复 CronJob
|
||||
func (h *K8sHandler) SuspendCronJob(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
var req struct {
|
||||
Suspend bool `json:"suspend"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.SuspendCronJob(namespace, name, req.Suspend); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "操作失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
action := "恢复"
|
||||
if req.Suspend {
|
||||
action = "暂停"
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 已" + action})
|
||||
}
|
||||
|
||||
// DeleteCronJob 删除 CronJob
|
||||
func (h *K8sHandler) DeleteCronJob(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.DeleteCronJob(namespace, name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除 CronJob 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "CronJob 已删除"})
|
||||
}
|
||||
|
||||
// TriggerCronJob 手动触发 CronJob
|
||||
func (h *K8sHandler) TriggerCronJob(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.TriggerCronJob(namespace, name); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "手动触发成功"})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deployment CI 集成 (Webhook 更新镜像)
|
||||
// ============================================================================
|
||||
|
||||
// UpdateDeploymentImage 更新 Deployment 容器镜像 (CI webhook)
|
||||
func (h *K8sHandler) UpdateDeploymentImage(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
namespace := c.Param("namespace")
|
||||
name := c.Param("name")
|
||||
|
||||
var req struct {
|
||||
Image string `json:"image"`
|
||||
ContainerName string `json:"container_name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Image == "" {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "image 不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := client.UpdateDeploymentImage(namespace, name, req.ContainerName, req.Image); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新镜像失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s,正在滚动更新...", req.Image)})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Namespace
|
||||
// ============================================================================
|
||||
|
||||
// ListNamespaces 获取命名空间列表
|
||||
func (h *K8sHandler) ListNamespaces(c *gin.Context) {
|
||||
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||
|
||||
client, _, err := h.getK8sClient(clusterID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
namespaces, err := client.ListNamespaces()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "K8s API error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: namespaces})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
func parseIntResource(val string) int {
|
||||
if val == "" {
|
||||
return 0
|
||||
|
||||
@@ -247,6 +247,28 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
clusters.GET("/:id/ingresses", k8sHandler.ListIngresses)
|
||||
clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li
|
||||
clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // Logan.Li
|
||||
|
||||
// Pod 日志 // Logan.Li
|
||||
clusters.GET("/:id/namespaces/:namespace/pods/:podName/logs", k8sHandler.GetPodLogs)
|
||||
// Pod 终端 exec (命令执行 / 文件浏览) // Logan.Li
|
||||
clusters.POST("/:id/namespaces/:namespace/pods/:podName/exec", k8sHandler.ExecInPod)
|
||||
// Deployment 扩缩容 & 重启 // Logan.Li
|
||||
clusters.POST("/:id/namespaces/:namespace/deployments/:name/scale", k8sHandler.ScaleDeployment)
|
||||
clusters.POST("/:id/namespaces/:namespace/deployments/:name/restart", k8sHandler.RestartDeployment)
|
||||
// Deployment CI 集成 (Webhook 更新镜像) // Logan.Li
|
||||
clusters.POST("/:id/namespaces/:namespace/deployments/:name/update-image", k8sHandler.UpdateDeploymentImage)
|
||||
// HPA 管理 // Logan.Li
|
||||
clusters.GET("/:id/hpas", k8sHandler.ListHPAs)
|
||||
clusters.POST("/:id/hpas", k8sHandler.CreateHPA)
|
||||
clusters.DELETE("/:id/namespaces/:namespace/hpas/:name", k8sHandler.DeleteHPA)
|
||||
// CronJob 管理 // Logan.Li
|
||||
clusters.GET("/:id/cronjobs", k8sHandler.ListCronJobs)
|
||||
clusters.POST("/:id/cronjobs", k8sHandler.CreateCronJob)
|
||||
clusters.POST("/:id/namespaces/:namespace/cronjobs/:name/suspend", k8sHandler.SuspendCronJob)
|
||||
clusters.POST("/:id/namespaces/:namespace/cronjobs/:name/trigger", k8sHandler.TriggerCronJob)
|
||||
clusters.DELETE("/:id/namespaces/:namespace/cronjobs/:name", k8sHandler.DeleteCronJob)
|
||||
// Namespace // Logan.Li
|
||||
clusters.GET("/:id/namespaces", k8sHandler.ListNamespaces)
|
||||
}
|
||||
|
||||
// 告警路由
|
||||
|
||||
@@ -373,3 +373,491 @@ func (c *Client) ListIngresses() (*IngressList, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// --- 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 (non-interactive command) ---
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
path := fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/exec?%s&stdout=true&stderr=true",
|
||||
namespace, podName, cmdStr)
|
||||
if container != "" {
|
||||
path += "&container=" + container
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", c.BaseURL+path, nil)
|
||||
if err != nil {
|
||||
return nil, 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")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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: "",
|
||||
}, 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
|
||||
}
|
||||
Reference in new issue
Block a user