feat(k8s): M5 Deployment 暂停更新 + CI/CD 集成

- 后端: 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)
This commit is contained in:
attacker committed 2026-06-23 03:06:13 +08:00
1 parent 23f237288d
commit 2e66908e20
8 files changed
+194 -8

No files matched your search

+2 -2
View File
@@ -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)
+8 -5
View File
@@ -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 按钮可跳转到流水线页面
---
+42
View File
@@ -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 发布按钮可跳转到流水线页面
@@ -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")) }
+5
View File
@@ -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
+21
View File
@@ -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
@@ -1,5 +1,5 @@
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 双模式: view(只读增强看板)/ edit(表单编辑器)
// 作者: Logan <admin@attacker.club>
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 ? <CheckCircle className="h-3 w-3 mr-1" /> : <AlertTriangle className="h-3 w-3 mr-1" />}
{ready}/{total}
</Badge>
{deployment?.paused && (
<Badge className="bg-orange-500/20 text-orange-400 border-orange-500/30">
<Pause className="h-3 w-3 mr-1" />已暂停更新
</Badge>
)}
</div>
<p className="text-xs text-slate-500 mt-0.5 truncate">
镜像: <code className="text-slate-400">{deployment.image || '-'}</code>
@@ -304,6 +333,22 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
<DropdownMenuItem className="text-slate-200 hover:bg-slate-800 cursor-pointer" onClick={handleRestart}>
<RotateCw className="h-4 w-4 mr-2 text-blue-400" /> 重启
</DropdownMenuItem>
<DropdownMenuItem
className="text-slate-200 hover:bg-slate-800 cursor-pointer"
disabled={!!actionLoading}
onClick={handleTogglePause}
>
{deployment?.paused
? <><Play className="h-4 w-4 mr-2 text-green-400" /> 恢复更新</>
: <><Pause className="h-4 w-4 mr-2 text-orange-400" /> 暂停更新</>
}
</DropdownMenuItem>
<DropdownMenuItem
className="text-slate-200 hover:bg-slate-800 cursor-pointer"
onClick={handleCICD}
>
<Rocket className="h-4 w-4 mr-2 text-purple-400" /> CI/CD 发布
</DropdownMenuItem>
<DropdownMenuItem className="text-red-400 hover:bg-slate-800 cursor-pointer" onClick={() => setModal({ kind: 'delete' })}>
<Trash2 className="h-4 w-4 mr-2" /> 删除
</DropdownMenuItem>
+6
View File
@@ -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 });
},