// Package cluster holds cluster-wide invariants that are not owned // by a single subsystem. The first inhabitant is the lead-eligibility // rule R-003: the cluster lead is always a bare Linux node; Proxmox // nodes are permanently ineligible because their kernel is shared // with guest VMs/containers and a lead failure there takes down the // hypervisor too. // // The package is deliberately decoupled from the scheduler: it owns // its own minimal NodeInfo (Hostname + Kind) so it can be unit-tested // without pulling in the scheduler's capacity model. The scheduler's // scheduler.NodeInfo has a `Kind string` field with the same values // ("linux", "proxmox"); callers convert at the boundary. package cluster import ( "errors" "fmt" "strings" ) // NodeKind classifies a node for lead-eligibility purposes (R-003). // The string values match scheduler.NodeInfo.Kind and model.NodeKind // so callers can pass either representation through without mapping. type NodeKind string const ( // NodeKindLinux is a bare Linux node — lead-eligible (R-003). NodeKindLinux NodeKind = "linux" // NodeKindProxmox is a Proxmox VE host — permanently lead- // ineligible (R-003): the hypervisor kernel is shared with // guests, so a lead process there is a blast-radius hazard. NodeKindProxmox NodeKind = "proxmox" ) // ErrProxmoxNotLead is returned when a Proxmox node is proposed as // the new cluster lead (R-003). var ErrProxmoxNotLead = errors.New("Proxmox nodes cannot hold the cluster lead role (R-003)") // ErrNodeNotRegistered is returned when the proposed lead is not in // the supplied node list at all. var ErrNodeNotRegistered = errors.New("cluster: proposed lead is not a registered node") // NodeInfo is the minimal node projection the lead rules need. It is // intentionally smaller than scheduler.NodeInfo so this package has // no upstream dependency on the scheduler. type NodeInfo struct { Hostname string Kind NodeKind } // IsLeadEligible reports whether a node of the given kind may hold // the cluster lead role (R-003). Linux nodes are eligible; Proxmox // nodes are permanently ineligible; any other kind (including the // empty string) is treated as ineligible. func IsLeadEligible(kind NodeKind) bool { return kind == NodeKindLinux } // ValidateLeadRotation checks that newLead is a registered Linux node // and refuses Proxmox nodes with ErrProxmoxNotLead (R-003). It returns // ErrNodeNotRegistered when newLead is not in nodes at all. The check // is case-sensitive on hostname; node registries in Orca are // case-normalized at the store layer so this matches reality. func ValidateLeadRotation(newLead string, nodes []NodeInfo) error { for _, n := range nodes { if n.Hostname != newLead { continue } if n.Kind == NodeKindProxmox { return ErrProxmoxNotLead } if n.Kind == NodeKindLinux { return nil } // Registered but neither linux nor proxmox (e.g. "localhost" // auto-registered node, or a future kind). Treat unknown kinds // as ineligible rather than guessing. return fmt.Errorf("cluster: node %q has ineligible kind %q: %w", newLead, n.Kind, ErrProxmoxNotLead) } // Not found in the registry at all. return fmt.Errorf("cluster: node %q not found: %w", newLead, ErrNodeNotRegistered) } // String renders a NodeKind for logs. It lowercases to match the // on-disk representation regardless of how the caller constructed it. func (k NodeKind) String() string { return strings.ToLower(string(k)) }