From 2e66908e2045c3e6cab14cd310ad64cff1748dbc Mon Sep 17 00:00:00 2001 From: attacker Date: Tue, 23 Jun 2026 03:06:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(k8s):=20M5=20Deployment=20=E6=9A=82?= =?UTF-8?q?=E5=81=9C=E6=9B=B4=E6=96=B0=20+=20CI/CD=20=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: PauseDeployment/ResumeDeployment 客户端方法 (PATCH spec.paused) - 后端: ID路由 + Kuboard code路由 pause/resume handlers - 后端: Deployment 列表 summary 增加 paused 字段 - 前端: 操作菜单增加「暂停更新/恢复更新」动态切换 + 暂停 Badge - 前端: CI/CD 发布按钮跳转流水线页面 (namespace+deployment 上下文) - 前端: api.ts 增加 pauseDeployment/resumeDeployment 方法 - 验证: 5项端到端测试全部通过 (Python urllib) --- PROJECT_EVALUATION.md | 4 +- ROADMAP.md | 13 ++-- TDD_PLAN.md | 42 ++++++++++++ backend/internal/api/handlers/kubernetes.go | 64 +++++++++++++++++++ backend/internal/api/routes/routes.go | 5 ++ backend/pkg/kubernetesclient/client.go | 21 ++++++ .../kubernetes/DeploymentWorkload.tsx | 47 +++++++++++++- frontend/src/services/api.ts | 6 ++ 8 files changed, 194 insertions(+), 8 deletions(-) diff --git a/PROJECT_EVALUATION.md b/PROJECT_EVALUATION.md index be98ab0..9f85b59 100644 --- a/PROJECT_EVALUATION.md +++ b/PROJECT_EVALUATION.md @@ -46,8 +46,8 @@ | ConfigMap/Secret 编辑 | ✅ | P2 | ✅ 已完成 M4 | | **Deployment 可视化编辑器** | **P1** | **M6-M9 进行中 — 当前编辑器只有 image/resources/probe(httpGet)/env/mounts,缺容器完整编辑/存储/调度/服务** | | **YAML 编辑器增强** | **P1** | **M6 — 当前是纯 textarea,需 Monaco/CodeMirror 语法高亮** | -| CI/CD 集成菜单项 | P3 | 菜单有按钮但未接线 | -| Deployment 暂停更新 | P3 | 菜单无此项 | +| CI/CD 集成菜单项 | ✅ | P3 | ✅ 已完成 M5 — 跳转流水线页面 | +| Deployment 暂停更新 | ✅ | P3 | ✅ 已完成 M5 — pause/resume 端点 + 暂停 Badge | ### 📋 Deployment 编辑器现状评估 (2026-06-23) diff --git a/ROADMAP.md b/ROADMAP.md index 04dbb3b..c6da56e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -70,16 +70,19 @@ --- -### M5: Deployment 暂停更新 + CI/CD 集成 [pending] +### M5: Deployment 暂停更新 + CI/CD 集成 [done] **目标**: 接通顶部菜单的暂停更新和 CI/CD 按钮 **改动**: -- 后端 `POST .../deployments/:name/pause` 和 `/resume` -- CI/CD 按钮跳转到已有 Pipeline 页面(传递 namespace+deployment 上下文) +- 后端 `POST .../deployments/:name/pause` 和 `/resume`(ID 路由 + Kuboard code 路由) +- 客户端 `PauseDeployment()` / `ResumeDeployment()` — PATCH `spec.paused` 布尔字段 +- Deployment 列表 summary 增加 `paused` 字段,前端可读取暂停状态 +- 前端操作菜单增加「暂停更新/恢复更新」动态切换 + 暂停 Badge +- CI/CD 按钮跳转到流水线页面(传递 namespace+deployment 上下文) **验收标准**: -- [ ] 可暂停/恢复 Deployment 滚动更新 -- [ ] CI/CD 按钮可跳转到流水线页面 +- [x] 可暂停/恢复 Deployment 滚动更新 +- [x] CI/CD 按钮可跳转到流水线页面 --- diff --git a/TDD_PLAN.md b/TDD_PLAN.md index 9664416..4ab8af4 100644 --- a/TDD_PLAN.md +++ b/TDD_PLAN.md @@ -94,3 +94,45 @@ curl -X POST "http://127.0.0.1:8080/api/v1/kubernetes/1/namespace/devops/pods/ai - [x] 文件列表 (ls) 能看到上传的文件 - [x] ExecInPod 使用 WebSocket channel.k8s.io 协议(不再用 SPDY raw HTTP) - [x] BusyBox ls 解析兼容(Mon DD HH:MM 格式) + +--- + +## M5: Deployment 暂停更新 + CI/CD 集成 + +### 验证用例 +```bash +# U1: 暂停前 — 确认 paused=false +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/devops/deployments" \ + -H "Authorization: Bearer $TOKEN" | jq '.data[] | select(.name=="redis") | .paused' +# 期望: false + +# U2: 暂停 Deployment +curl -X POST "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/devops/deployments/redis/pause" \ + -H "Authorization: Bearer $TOKEN" +# 期望: code=200, msg="已暂停滚动更新" + +# U3: 暂停后 — 确认 paused=true +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/devops/deployments" \ + -H "Authorization: Bearer $TOKEN" | jq '.data[] | select(.name=="redis") | .paused' +# 期望: true + +# U4: 恢复 Deployment +curl -X POST "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/devops/deployments/redis/resume" \ + -H "Authorization: Bearer $TOKEN" +# 期望: code=200, msg="已恢复滚动更新" + +# U5: 恢复后 — 确认 paused=false +curl -s "http://127.0.0.1:8080/api/v1/kubernetes/prod-cluster/namespace/devops/deployments" \ + -H "Authorization: Bearer $TOKEN" | jq '.data[] | select(.name=="redis") | .paused' +# 期望: false +``` + +### 通过标准 +- [x] U1: 暂停前 paused=false +- [x] U2: pause API 返回 200 +- [x] U3: 暂停后 paused=true +- [x] U4: resume API 返回 200 +- [x] U5: 恢复后 paused=false +- [x] ID 路由 + Kuboard code 路由均可用 +- [x] 前端操作菜单显示「暂停更新/恢复更新」动态切换 +- [x] CI/CD 发布按钮可跳转到流水线页面 diff --git a/backend/internal/api/handlers/kubernetes.go b/backend/internal/api/handlers/kubernetes.go index 5f09701..4230536 100644 --- a/backend/internal/api/handlers/kubernetes.go +++ b/backend/internal/api/handlers/kubernetes.go @@ -1163,6 +1163,46 @@ func (h *KubernetesHandler) RestartDeployment(c *gin.Context) { c.JSON(http.StatusOK, models.Response{Code: 200, Message: "重启指令已下发"}) } +// PauseDeployment 暂停 Deployment 滚动更新(spec.paused=true) +func (h *KubernetesHandler) PauseDeployment(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.PauseDeployment(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: "已暂停滚动更新"}) +} + +// ResumeDeployment 恢复 Deployment 滚动更新(spec.paused=false) +func (h *KubernetesHandler) ResumeDeployment(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.ResumeDeployment(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 管理 // ============================================================================ @@ -1543,6 +1583,12 @@ func (h *KubernetesHandler) RestartDeploymentNS(c *gin.Context) { func (h *KubernetesHandler) UpdateDeploymentImageNS(c *gin.Context) { h.updateDeploymentImageWithNS(c, c.Param("namespace")) } +func (h *KubernetesHandler) PauseDeploymentNS(c *gin.Context) { + h.pauseDeploymentWithNS(c, c.Param("namespace")) +} +func (h *KubernetesHandler) ResumeDeploymentNS(c *gin.Context) { + h.resumeDeploymentWithNS(c, c.Param("namespace")) +} func (h *KubernetesHandler) GetPodLogsNS(c *gin.Context) { h.getPodLogsWithNS(c, c.Param("namespace")) } @@ -1565,6 +1611,7 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { AvailableReplicas int `json:"available_replicas"` ReadyReplicas int `json:"ready_replicas"` Image string `json:"image"` + Paused bool `json:"paused"` } result := []DepSummary{} for _, d := range deps.Items { @@ -1577,6 +1624,7 @@ func (h *KubernetesHandler) listDeploymentsWithNS(c *gin.Context, ns string) { AvailableReplicas: d.Status.AvailableReplicas, ReadyReplicas: d.Status.ReadyReplicas, Image: image, + Paused: d.Spec.Paused, }) } c.JSON(200, models.Response{Code: 200, Data: result}) @@ -1816,6 +1864,22 @@ func (h *KubernetesHandler) updateDeploymentImageWithNS(c *gin.Context, ns strin if err := client.UpdateDeploymentImage(ns, c.Param("name"), req.ContainerName, req.Image); err != nil { c.JSON(500, models.Response{Code: 500, Message: "更新镜像失败: " + err.Error()}); return } c.JSON(200, models.Response{Code: 200, Message: fmt.Sprintf("镜像已更新为 %s", req.Image)}) } +func (h *KubernetesHandler) pauseDeploymentWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if err := client.PauseDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "暂停更新失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "已暂停滚动更新"}) +} +func (h *KubernetesHandler) resumeDeploymentWithNS(c *gin.Context, ns string) { + clusterID, _ := strconv.Atoi(c.Param("id")) + if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } + client, _, err := h.getK8sClient(clusterID) + if err != nil { c.JSON(500, models.Response{Code: 500, Message: err.Error()}); return } + if err := client.ResumeDeployment(ns, c.Param("name")); err != nil { c.JSON(500, models.Response{Code: 500, Message: "恢复更新失败: " + err.Error()}); return } + c.JSON(200, models.Response{Code: 200, Message: "已恢复滚动更新"}) +} func (h *KubernetesHandler) getPodLogsWithNS(c *gin.Context, ns string) { clusterID, _ := strconv.Atoi(c.Param("id")) if clusterID == 0 { clusterID = h.resolveClusterCode(c.Param("clusterCode")) } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index c6ac82d..95316fe 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -298,6 +298,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c // Deployment 扩缩容 & 重启 // Logan.Li clusters.POST("/:id/namespaces/:namespace/deployments/:name/scale", k8sHandler.ScaleDeployment) clusters.POST("/:id/namespaces/:namespace/deployments/:name/restart", k8sHandler.RestartDeployment) + // Deployment 暂停/恢复滚动更新 // Logan.Li M5 + clusters.POST("/:id/namespaces/:namespace/deployments/:name/pause", k8sHandler.PauseDeployment) + clusters.POST("/:id/namespaces/:namespace/deployments/:name/resume", k8sHandler.ResumeDeployment) // Deployment CI 集成 (Webhook 更新镜像) // Logan.Li clusters.POST("/:id/namespaces/:namespace/deployments/:name/update-image", k8sHandler.UpdateDeploymentImage) // HPA 管理 // Logan.Li @@ -337,6 +340,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c kubernetesRouter.GET("/:clusterCode/namespace/:namespace/deployments", k8sHandler.ListDeploymentsNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/scale", k8sHandler.ScaleDeploymentNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/restart", k8sHandler.RestartDeploymentNS) + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/pause", k8sHandler.PauseDeploymentNS) + kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/resume", k8sHandler.ResumeDeploymentNS) kubernetesRouter.POST("/:clusterCode/namespace/:namespace/deployments/:name/update-image", k8sHandler.UpdateDeploymentImageNS) // Pods diff --git a/backend/pkg/kubernetesclient/client.go b/backend/pkg/kubernetesclient/client.go index cd50b6d..ce1fa00 100644 --- a/backend/pkg/kubernetesclient/client.go +++ b/backend/pkg/kubernetesclient/client.go @@ -291,6 +291,7 @@ type DeploymentItem struct { type DeploymentSpec struct { Replicas int `json:"replicas"` Template DeploymentTemplate `json:"template"` + Paused bool `json:"paused"` } type DeploymentTemplate struct { @@ -586,6 +587,26 @@ func (c *Client) RestartDeployment(namespace, name string) error { return c.patch(path, body, nil) } +// PauseDeployment pauses rollout of a Deployment by setting spec.paused=true. +// A paused Deployment will not process new rollouts — useful for debugging or +// staged updates. Resume with ResumeDeployment. +func (c *Client) PauseDeployment(namespace, name string) error { + body := map[string]interface{}{ + "spec": map[string]interface{}{"paused": true}, + } + path := fmt.Sprintf("/apis/apps/v1/namespaces/%s/deployments/%s", namespace, name) + return c.patch(path, body, nil) +} + +// ResumeDeployment resumes rollout of a paused Deployment by setting spec.paused=false. +func (c *Client) ResumeDeployment(namespace, name string) error { + body := map[string]interface{}{ + "spec": map[string]interface{}{"paused": false}, + } + 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 diff --git a/frontend/src/components/kubernetes/DeploymentWorkload.tsx b/frontend/src/components/kubernetes/DeploymentWorkload.tsx index fe2dafc..b162bf6 100644 --- a/frontend/src/components/kubernetes/DeploymentWorkload.tsx +++ b/frontend/src/components/kubernetes/DeploymentWorkload.tsx @@ -1,5 +1,5 @@ // DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件 -// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 删除 | 元数据 | 运行时 | 服务 | 应用路由 +// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由 // - 双模式: view(只读增强看板)/ edit(表单编辑器) // 作者: Logan import { useState, useEffect, useCallback } from 'react'; @@ -17,6 +17,7 @@ import { Trash2, FileText, Terminal, RefreshCw, Loader2, X, ChevronDown, Cpu, Network, GitBranch, Info, CheckCircle, AlertTriangle, XCircle, + Pause, Play, Rocket, } from 'lucide-react'; import { kubernetesApi } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; @@ -163,6 +164,29 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod } finally { setActionLoading(''); } }; + const handleTogglePause = async () => { + const isPaused = !!deployment?.paused; + const action = isPaused ? '恢复' : '暂停'; + if (!confirm(`确认${action} Deployment "${deploymentName}" 的滚动更新?`)) return; + setActionLoading(isPaused ? 'resume' : 'pause'); + try { + if (isPaused) { + await kubernetesApi.resumeDeployment(clusterCode, namespace, deploymentName); + } else { + await kubernetesApi.pauseDeployment(clusterCode, namespace, deploymentName); + } + toast({ title: `${action}成功`, description: `Deployment 滚动更新已${action}` }); + setTimeout(loadData, 1000); + } catch (e: any) { + toast({ title: `${action}失败`, description: e?.message || String(e), variant: 'destructive' }); + } finally { setActionLoading(''); } + }; + + const handleCICD = () => { + // 跳转到流水线页面,传递 namespace + deployment 上下文 + navigate(`/pipelines?namespace=${encodeURIComponent(namespace)}&app=${encodeURIComponent(deploymentName)}`); + }; + const handleUpdateImage = async () => { if (!newImage.trim()) return; setActionLoading('image'); @@ -250,6 +274,11 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod {isHealthy ? : } {ready}/{total} + {deployment?.paused && ( + + 已暂停更新 + + )}

镜像: {deployment.image || '-'} @@ -304,6 +333,22 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod 重启 + + {deployment?.paused + ? <> 恢复更新 + : <> 暂停更新 + } + + + CI/CD 发布 + setModal({ kind: 'delete' })}> 删除 diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f085869..b71ed04 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -344,6 +344,12 @@ export const kubernetesApi = { restartDeployment: async (clusterCode: string, namespace: string, name: string) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/restart`); }, + pauseDeployment: async (clusterCode: string, namespace: string, name: string) => { + return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/pause`); + }, + resumeDeployment: async (clusterCode: string, namespace: string, name: string) => { + return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/resume`); + }, updateDeploymentImage: async (clusterCode: string, namespace: string, name: string, image: string, containerName?: string) => { return http.post(`/kubernetes/${clusterCode}/namespace/${namespace}/deployments/${name}/update-image`, { image, container_name: containerName }); },