feat(delivery): support multi-instance scheduling on a single host

- Per-host instance limit (DELIVERY_HOST_INSTANCE_LIMIT, default 4)
- Optional target_host to pin a host from the candidate pool
- Regenerate swagger docs
This commit is contained in:
Hungerdream
2026-07-27 15:19:44 +08:00
parent bfb4f0df4d
commit 41b2f45420
7 changed files with 355 additions and 439 deletions
+47 -10
View File
@@ -24,6 +24,9 @@ import (
var dnsLabelPattern = regexp.MustCompile(`^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$`)
// AWX inventory 主机名:允许字母数字、中划线与点(FQDN)。
var hostNamePattern = regexp.MustCompile(`^[a-zA-Z0-9](?:[-a-zA-Z0-9.]*[a-zA-Z0-9])?$`)
type MySQLDeliveryInput struct {
BusinessLineID uint64 `json:"business_line_id" binding:"required"`
TargetID uint64 `json:"target_id" binding:"required"`
@@ -33,9 +36,11 @@ type MySQLDeliveryInput struct {
Topology string `json:"topology"`
MySQLPort int `json:"mysql_port"`
DataDisk string `json:"data_disk"`
CPUMilli int64 `json:"cpu_milli" binding:"required"`
MemoryMi int64 `json:"memory_mi" binding:"required"`
StorageGi int64 `json:"storage_gi" binding:"required"`
// 调度控制(选填):点名候选池内主机跳过自动选机,端口/配额/实机守卫照常执行
TargetHost string `json:"target_host"`
CPUMilli int64 `json:"cpu_milli" binding:"required"`
MemoryMi int64 `json:"memory_mi" binding:"required"`
StorageGi int64 `json:"storage_gi" binding:"required"`
// 数据库配置(选填,缺省由 playbook 基线兜底)
Timezone string `json:"timezone"`
LowerCaseTableNames *int `json:"lower_case_table_names"`
@@ -93,20 +98,31 @@ func parseTargetMetadata(raw string) targetMetadata {
return meta
}
// firstFreeHost 返回候选池中第一个未被占用的节点。
func firstFreeHost(hosts []targetHost, occupied []string) *targetHost {
taken := make(map[string]bool, len(occupied))
for _, h := range occupied {
taken[h] = true
// firstFreeHost 返回候选池中非失败任务数未达单机实例上限的第一个节点;
// limit < 1 时按 1 兜底(退化为旧的一机一实例语义)。顺序遍历天然形成"先摊开、摊满一轮再叠加"。
func firstFreeHost(hosts []targetHost, occupied []string, limit int) *targetHost {
if limit < 1 {
limit = 1
}
for i := range hosts {
if !taken[hosts[i].Name] {
if hostTaskCount(occupied, hosts[i].Name) < limit {
return &hosts[i]
}
}
return nil
}
// hostTaskCount 统计某主机在占用清单(非失败任务的 target_host 列表,含重复)中的出现次数。
func hostTaskCount(occupied []string, name string) int {
count := 0
for _, h := range occupied {
if h == name {
count++
}
}
return count
}
// 端口池 13306-13999:混合分配模型,用户留空时自动分配,可覆盖为池内指定端口。
const (
mysqlPortPoolStart = 13306
@@ -344,6 +360,9 @@ func validateDeliveryInput(input MySQLDeliveryInput, dataDisks []string) error {
return fmt.Errorf("data_disk %q is not in the allowed mount point list %v", input.DataDisk, allowed)
}
}
if input.TargetHost != "" && (len(input.TargetHost) > 253 || !hostNamePattern.MatchString(input.TargetHost)) {
return fmt.Errorf("target_host must be a valid inventory host name")
}
if input.Timezone != "" && !timezonePattern.MatchString(input.Timezone) {
return fmt.Errorf("timezone must be an offset like +08:00, SYSTEM, or a named zone like Asia/Shanghai")
}
@@ -498,7 +517,25 @@ func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryT
if err := tx.Model(&model.DeliveryTask{}).Where("target_id = ? AND target_host <> ? AND status NOT IN ?", task.TargetID, "", occupiedExclude).Pluck("target_host", &occupied).Error; err != nil {
return err
}
host := firstFreeHost(meta.Hosts, occupied)
limit := s.cfg.DeliveryHostInstanceLimit
var host *targetHost
if payload.TargetHost != "" {
// 点名主机:仅校验池内存在性与单机实例上限,端口/配额/实机守卫照常执行。
for i := range meta.Hosts {
if meta.Hosts[i].Name == payload.TargetHost {
host = &meta.Hosts[i]
break
}
}
if host == nil {
return s.failInTransaction(tx, &task, model.TaskValidationFailed, fmt.Sprintf("target_host %q is not in the candidate host pool", payload.TargetHost))
}
if effective := max(limit, 1); hostTaskCount(occupied, host.Name) >= effective {
return fmt.Errorf("defer: pinned host %s reached the per-host instance limit %d", host.Name, effective)
}
} else {
host = firstFreeHost(meta.Hosts, occupied, limit)
}
if host == nil {
return fmt.Errorf("defer: no free host available on target")
}
+26
View File
@@ -15,6 +15,7 @@ func TestValidateDeliveryInput(t *testing.T) {
full.Topology = "standalone"
full.MySQLPort = 13306
full.DataDisk = "/disk1"
full.TargetHost = "k8s-server-03"
full.MemoryMi = 8192
full.StorageGi = 2000
full.Timezone = "+08:00"
@@ -55,6 +56,7 @@ func TestValidateDeliveryInput(t *testing.T) {
"port below pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 3307 },
"port above pool": func(in *MySQLDeliveryInput) { in.MySQLPort = 14000 },
"data disk not in list": func(in *MySQLDeliveryInput) { in.DataDisk = "/mnt/other" },
"bad target host": func(in *MySQLDeliveryInput) { in.TargetHost = "-bad-host" },
"bad timezone": func(in *MySQLDeliveryInput) { in.Timezone = "UTC+8" },
"bad lower case": func(in *MySQLDeliveryInput) { in.LowerCaseTableNames = intPtr(2) },
"bad charset": func(in *MySQLDeliveryInput) { in.CharacterSet = "big5" },
@@ -77,6 +79,30 @@ func TestValidateDeliveryInput(t *testing.T) {
}
}
func TestFirstFreeHost(t *testing.T) {
hosts := []targetHost{{Name: "node-a"}, {Name: "node-b"}}
if h := firstFreeHost(hosts, nil, 1); h == nil || h.Name != "node-a" {
t.Fatalf("expected node-a on empty occupancy, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a"}, 1); h == nil || h.Name != "node-b" {
t.Fatalf("expected node-b when node-a is full at limit 1, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a", "node-b"}, 1); h != nil {
t.Fatalf("limit 1 with all hosts taken should return nil, got %+v", h)
}
// 摊满一轮后回到首台叠加第二个实例
if h := firstFreeHost(hosts, []string{"node-a", "node-b"}, 2); h == nil || h.Name != "node-a" {
t.Fatalf("expected node-a for second round at limit 2, got %+v", h)
}
if h := firstFreeHost(hosts, []string{"node-a", "node-a", "node-b", "node-b"}, 2); h != nil {
t.Fatalf("limit 2 with all hosts saturated should return nil, got %+v", h)
}
// limit < 1 退化为一机一实例
if h := firstFreeHost(hosts, []string{"node-a"}, 0); h == nil || h.Name != "node-b" {
t.Fatalf("limit 0 should degrade to 1, got %+v", h)
}
}
func TestAllocatePort(t *testing.T) {
if port, err := allocatePort(0, nil); err != nil || port != mysqlPortPoolStart {
t.Fatalf("expected first pool port %d, got %d err=%v", mysqlPortPoolStart, port, err)