mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-06 20:02:41 +08:00
节点池动态调度(企业集群核心需求): - model.Node 新增 Labels CSV;Node.HasLabel / LabelSet 辅助方法 - model.BackupTask 新增 NodePoolTag;与 NodeID 互斥(校验层拒绝同时设置) - BackupExecutionService.selectPoolNode:匹配标签的在线节点中选"运行中任务最少" 并列按 ID 升序稳定;空池返回 NODE_POOL_EMPTY 让用户立即感知 - 选中节点仅写 BackupRecord,不回写 task.NodeID —— 每次执行重选实现真轮转均衡 Grafana Dashboard(v2.1 指标的可视化闭环): - deploy/grafana/backupx-dashboard.json:11 个面板覆盖概览/时序/容量/集群 - deploy/grafana/README.md:Prometheus 抓取配置 + 告警建议 - release workflow 打包 grafana/ + nginx.conf 到 tar.gz 前端: - 节点列表:Agent 版本 vs Master 不一致时橙红 Tag + Tooltip 提示升级 - 节点列表新增"标签/节点池"列,支持 CSV 编辑 + 并发/带宽一起改 - 任务表单新增 NodePoolTag 输入框,与节点选择器互斥禁用 测试: - model/node_label_test.go:HasLabel / LabelSet / nil 安全 - service/node_pool_scheduler_test.go:负载最低优先 / 空池错误 / nil repo 降级 - go test ./... + npm run build 全绿
84 lines
2.8 KiB
Go
84 lines
2.8 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
"time"
|
||
|
||
"backupx/server/internal/apperror"
|
||
"backupx/server/internal/model"
|
||
)
|
||
|
||
// nodeRepoStub 返回预设节点切片;仅关注 List/FindByID。
|
||
// 其余方法返回零值,避免在调度路径被调用到。
|
||
type nodeRepoStub struct {
|
||
nodes []model.Node
|
||
}
|
||
|
||
func (s *nodeRepoStub) List(context.Context) ([]model.Node, error) { return s.nodes, nil }
|
||
func (s *nodeRepoStub) FindByID(_ context.Context, id uint) (*model.Node, error) {
|
||
for i := range s.nodes {
|
||
if s.nodes[i].ID == id {
|
||
return &s.nodes[i], nil
|
||
}
|
||
}
|
||
return nil, nil
|
||
}
|
||
func (s *nodeRepoStub) FindByToken(context.Context, string) (*model.Node, error) { return nil, nil }
|
||
func (s *nodeRepoStub) FindLocal(context.Context) (*model.Node, error) { return nil, nil }
|
||
func (s *nodeRepoStub) Create(context.Context, *model.Node) error { return nil }
|
||
func (s *nodeRepoStub) BatchCreate(context.Context, []*model.Node) error { return nil }
|
||
func (s *nodeRepoStub) Update(context.Context, *model.Node) error { return nil }
|
||
func (s *nodeRepoStub) Delete(context.Context, uint) error { return nil }
|
||
func (s *nodeRepoStub) MarkStaleOffline(context.Context, time.Time) (int64, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func TestSelectPoolNode_PicksLeastLoaded(t *testing.T) {
|
||
nodes := []model.Node{
|
||
{ID: 1, Name: "node-a", Status: model.NodeStatusOnline, Labels: "prod,db"},
|
||
{ID: 2, Name: "node-b", Status: model.NodeStatusOnline, Labels: "prod,db"},
|
||
{ID: 3, Name: "node-offline", Status: model.NodeStatusOffline, Labels: "prod,db"},
|
||
{ID: 4, Name: "node-other-pool", Status: model.NodeStatusOnline, Labels: "staging"},
|
||
}
|
||
svc := &BackupExecutionService{
|
||
nodeRepo: &nodeRepoStub{nodes: nodes},
|
||
records: nil, // 触发 countRunningOnNode 返回 0,节点并列时按 ID 升序
|
||
}
|
||
chosen, err := svc.selectPoolNode(context.Background(), "db")
|
||
if err != nil {
|
||
t.Fatalf("unexpected err: %v", err)
|
||
}
|
||
if chosen == nil || chosen.ID != 1 {
|
||
t.Fatalf("expected node-a (ID=1), got %#v", chosen)
|
||
}
|
||
}
|
||
|
||
func TestSelectPoolNode_EmptyPoolReturnsError(t *testing.T) {
|
||
svc := &BackupExecutionService{
|
||
nodeRepo: &nodeRepoStub{nodes: []model.Node{
|
||
{ID: 1, Status: model.NodeStatusOnline, Labels: "prod"},
|
||
}},
|
||
}
|
||
_, err := svc.selectPoolNode(context.Background(), "missing-pool")
|
||
if err == nil {
|
||
t.Fatal("expected empty-pool error")
|
||
}
|
||
var apperr *apperror.AppError
|
||
if !errors.As(err, &apperr) || apperr.Code != "NODE_POOL_EMPTY" {
|
||
t.Errorf("expected NODE_POOL_EMPTY, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestSelectPoolNode_NilRepoDegradesGracefully(t *testing.T) {
|
||
svc := &BackupExecutionService{}
|
||
got, err := svc.selectPoolNode(context.Background(), "any")
|
||
if err != nil {
|
||
t.Errorf("nil repo should degrade silently, got err %v", err)
|
||
}
|
||
if got != nil {
|
||
t.Errorf("nil repo should return nil node, got %v", got)
|
||
}
|
||
}
|