/* Volt Cluster — Workload Scheduler. Implements scheduling strategies for assigning workloads to cluster nodes. The scheduler considers: - Resource availability (CPU, memory, disk) - Label selectors and affinity rules - Node health status - Current workload distribution (spread/pack strategies) Strategies: - BinPack: Pack workloads onto fewest nodes (maximize density) - Spread: Distribute evenly across nodes (maximize availability) - Manual: Explicit node selection by name/label Copyright (c) Armored Gates LLC. All rights reserved. AGPSL v5 — Source-available. Anti-competition clauses apply. */ package cluster import ( "fmt" "sort" ) // ── Strategy ───────────────────────────────────────────────────────────────── // ScheduleStrategy defines how workloads are assigned to nodes. type ScheduleStrategy string const ( StrategyBinPack ScheduleStrategy = "binpack" StrategySpread ScheduleStrategy = "spread" StrategyManual ScheduleStrategy = "manual" ) // ── Scheduler ──────────────────────────────────────────────────────────────── // Scheduler assigns workloads to nodes based on a configurable strategy. type Scheduler struct { strategy ScheduleStrategy } // NewScheduler creates a scheduler with the given strategy. func NewScheduler(strategy ScheduleStrategy) *Scheduler { if strategy == "" { strategy = StrategyBinPack } return &Scheduler{strategy: strategy} } // SelectNode chooses the best node for a workload based on the current strategy. // Returns the selected NodeInfo or an error if no suitable node exists. func (s *Scheduler) SelectNode( nodes []*NodeInfo, required WorkloadResources, selector map[string]string, existingSchedule []*ScheduledWorkload, ) (*NodeInfo, error) { // Filter to eligible nodes eligible := s.filterEligible(nodes, required, selector) if len(eligible) == 0 { return nil, fmt.Errorf("no eligible nodes: checked %d nodes, none meet resource/label requirements", len(nodes)) } switch s.strategy { case StrategySpread: return s.selectSpread(eligible, existingSchedule), nil case StrategyBinPack: return s.selectBinPack(eligible), nil case StrategyManual: // Manual strategy returns the first eligible node matching the selector return eligible[0], nil default: return s.selectBinPack(eligible), nil } } // filterEligible returns nodes that are healthy, match labels, and have sufficient resources. func (s *Scheduler) filterEligible(nodes []*NodeInfo, required WorkloadResources, selector map[string]string) []*NodeInfo { var eligible []*NodeInfo for _, node := range nodes { // Must be ready if node.Status != NodeStatusReady { continue } // Must match label selector if !matchLabels(node.Labels, selector) { continue } // Must have sufficient resources availMem := node.Resources.MemoryTotalMB - node.Resources.MemoryUsedMB if required.MemoryMB > 0 && availMem < required.MemoryMB { continue } // CPU check (basic — just core count) if required.CPUCores > 0 && node.Resources.CPUCores < required.CPUCores { continue } // Disk check availDisk := (node.Resources.DiskTotalGB - node.Resources.DiskUsedGB) * 1024 // convert to MB if required.DiskMB > 0 && availDisk < required.DiskMB { continue } eligible = append(eligible, node) } return eligible } // selectBinPack picks the node with the LEAST available memory (pack tight). func (s *Scheduler) selectBinPack(nodes []*NodeInfo) *NodeInfo { sort.Slice(nodes, func(i, j int) bool { availI := nodes[i].Resources.MemoryTotalMB - nodes[i].Resources.MemoryUsedMB availJ := nodes[j].Resources.MemoryTotalMB - nodes[j].Resources.MemoryUsedMB return availI < availJ // least available first }) return nodes[0] } // selectSpread picks the node with the fewest currently scheduled workloads. func (s *Scheduler) selectSpread(nodes []*NodeInfo, schedule []*ScheduledWorkload) *NodeInfo { // Count workloads per node counts := make(map[string]int) for _, sw := range schedule { if sw.Status == "running" || sw.Status == "pending" { counts[sw.NodeID]++ } } // Sort by workload count (ascending) sort.Slice(nodes, func(i, j int) bool { return counts[nodes[i].NodeID] < counts[nodes[j].NodeID] }) return nodes[0] } // ── Scoring (for future extensibility) ─────────────────────────────────────── // NodeScore represents a scored node for scheduling decisions. type NodeScore struct { Node *NodeInfo Score float64 } // ScoreNodes evaluates and ranks all eligible nodes for a workload. // Higher scores are better. func ScoreNodes(nodes []*NodeInfo, required WorkloadResources) []NodeScore { var scores []NodeScore for _, node := range nodes { if node.Status != NodeStatusReady { continue } score := 0.0 // Resource availability score (0-50 points) if node.Resources.MemoryTotalMB > 0 { memPct := float64(node.Resources.MemoryTotalMB-node.Resources.MemoryUsedMB) / float64(node.Resources.MemoryTotalMB) score += memPct * 50 } // CPU headroom score (0-25 points) if node.Resources.CPUCores > required.CPUCores { score += 25 } // Health score (0-25 points) if node.MissedBeats == 0 { score += 25 } else { score += float64(25-node.MissedBeats*5) if score < 0 { score = 0 } } scores = append(scores, NodeScore{Node: node, Score: score}) } sort.Slice(scores, func(i, j int) bool { return scores[i].Score > scores[j].Score }) return scores }