Files
llmgw/internal/control/facility.go
T
Ilker ManapandClaude Opus 5.5 6289b01ed6 The cluster's settings live in the store, and every change takes effect
Everything in server.yaml that is not one machine's own is now a versioned
document in the store (cluster_settings), the same on every control plane.
The first start against a store imports the file's cluster keys, with the
baseline guard policy when there is none; after that those keys in a file
are ignored, with a warning.

Each control plane polls the document's version and its own server.yaml's
hash. A change is validated and applied: guard live (Gate.Replace), anything
else by a graceful restart that systemd brings back, standbys first and the
leader 30s later. A version that does not load on a machine is not applied;
at start the newest one that loads is used, so a bad edit cannot keep every
control plane down.

llmgw ctl config show/get/set/unset/edit/history/rollback, the admin API
under /api/v1/settings, and the console's Settings screen, which layers the
store's document over the machine's file, write it. Commands beside the
control plane that read cluster keys (init, node add, server add, backup,
upgrade) read the layered configuration; init --bundle writes offline to
the store once it holds the settings.

A standby that becomes the leader re-reads models, engine choices and
facility holds from the store.

Retires appendGuardIfMissing: the import does it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 15:25:23 +02:00

249 lines
7.8 KiB
Go

package control
import (
"context"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// FacilityStore is where holds and operator labels are kept.
type FacilityStore interface {
FacilityHolds(ctx context.Context) ([]store.FacilityHold, error)
SetFacilityHold(ctx context.Context, h store.FacilityHold) error
DeleteFacilityHold(ctx context.Context, domain string) error
NodeLabels(ctx context.Context) (map[string]map[string]string, error)
SetNodeLabel(ctx context.Context, node, key, value, by string) error
}
// Facility is the operator's side of facility-aware placement: the budgets
// from server.yaml, the domains held for maintenance, and the labels set on
// nodes through the control plane. It keeps them in the store when there is
// one, and puts them in force in the inventory, where every placement —
// the reconciler's, headroom's and preemption's dry runs — reads them.
//
// Without a store it still works, for the life of the process: a held rack
// is worth honouring even on a control plane that will forget it.
type Facility struct {
inv *inventory.Store
log *slog.Logger
mu sync.Mutex
st FacilityStore
base facility.Policy
holds map[facility.Domain]facility.Hold
}
// NewFacility starts with nothing held and nothing capped.
func NewFacility(inv *inventory.Store, log *slog.Logger) *Facility {
if log == nil {
log = slog.Default()
}
return &Facility{inv: inv, log: log, holds: map[facility.Domain]facility.Hold{}}
}
// SetPolicy puts the budgets and estimates from configuration in force. Its
// holds are ignored: those are the operator's, kept here.
func (f *Facility) SetPolicy(p facility.Policy) {
f.mu.Lock()
defer f.mu.Unlock()
f.base = p.Clone()
f.applyLocked()
}
// Policy is what placement reads now.
func (f *Facility) Policy() facility.Policy { return f.inv.Facility() }
// UseStore keeps holds and labels in the store from now on, and puts in
// force what it already holds.
func (f *Facility) UseStore(ctx context.Context, st FacilityStore) {
f.mu.Lock()
defer f.mu.Unlock()
f.st = st
f.loadLocked(ctx)
}
// Reload reads the holds and labels in the store again, as a standby
// becoming the leader does.
func (f *Facility) Reload(ctx context.Context) {
f.mu.Lock()
defer f.mu.Unlock()
if f.st == nil {
return
}
f.holds = map[facility.Domain]facility.Hold{}
f.loadLocked(ctx)
}
func (f *Facility) loadLocked(ctx context.Context) {
st := f.st
if holds, err := st.FacilityHolds(ctx); err != nil {
f.log.Error("could not read the domains held for maintenance; none is held until they are set again", "err", err)
} else {
for _, h := range holds {
d, err := facility.ParseDomain(h.Domain)
if err != nil {
continue
}
f.holds[d] = facility.Hold{Domain: d, Reason: h.Reason, Drain: h.Drain, By: h.By, At: h.At}
}
}
if labels, err := st.NodeLabels(ctx); err != nil {
f.log.Error("could not read the labels set on nodes; nodes carry only their agents' until they are set again", "err", err)
} else {
for node, l := range labels {
f.inv.SetOperatorLabels(node, l)
}
}
f.applyLocked()
}
func (f *Facility) applyLocked() {
p := f.base.Clone()
p.Holds = maps.Clone(f.holds)
f.inv.SetFacility(p)
}
// Holds are the domains held, oldest first.
func (f *Facility) Holds() []facility.Hold {
f.mu.Lock()
defer f.mu.Unlock()
out := slices.Collect(maps.Values(f.holds))
slices.SortFunc(out, func(a, b facility.Hold) int {
if c := a.At.Compare(b.At); c != 0 {
return c
}
return strings.Compare(string(a.Domain), string(b.Domain))
})
return out
}
// Hold takes a domain out of placement, and with drain moves the work in it
// elsewhere: each of its nodes is drained, one replica at a time with the
// replacement up first, as `ctl drain` does. It answers the nodes drained.
func (f *Facility) Hold(ctx context.Context, h facility.Hold) ([]string, error) {
if h.At.IsZero() {
h.At = time.Now()
}
f.mu.Lock()
defer f.mu.Unlock()
if f.st != nil {
if err := f.st.SetFacilityHold(ctx, store.FacilityHold{
Domain: string(h.Domain), Reason: h.Reason, Drain: h.Drain, At: h.At, By: h.By,
}); err != nil {
return nil, fmt.Errorf("keeping the hold: %w", err)
}
}
f.holds[h.Domain] = h
f.applyLocked()
var drained []string
if h.Drain {
for _, n := range f.inv.Nodes() {
if slices.Contains(n.Domains(), h.Domain) && (n.State == inventory.Ready || n.State == inventory.Cordoned) {
f.inv.SetState(n.ID, inventory.Draining)
drained = append(drained, n.ID)
}
}
}
f.log.Info("domain held: nothing new is placed in it", "domain", h.Domain, "reason", h.Reason,
"drain", h.Drain, "by", h.By, "drained", drained)
return drained, nil
}
// Release puts a domain back into placement. Nodes a draining hold drained
// are made ready again, unless another hold still covers them; a node an
// operator drained by itself is not told apart from those, so it is readied
// too, which is what releasing its rack asks for.
func (f *Facility) Release(ctx context.Context, d facility.Domain) (facility.Hold, []string, error) {
f.mu.Lock()
defer f.mu.Unlock()
h, ok := f.holds[d]
if !ok {
return facility.Hold{}, nil, fmt.Errorf("%s is not held; `llmgw ctl facility` lists what is", d)
}
if f.st != nil {
if err := f.st.DeleteFacilityHold(ctx, string(d)); err != nil {
return facility.Hold{}, nil, fmt.Errorf("releasing the hold: %w", err)
}
}
delete(f.holds, d)
f.applyLocked()
var readied []string
if h.Drain {
pol := f.inv.Facility()
for _, n := range f.inv.Nodes() {
doms := n.Domains()
if !slices.Contains(doms, d) || n.State != inventory.Draining {
continue
}
if _, still := pol.HeldIn(doms); still {
continue
}
f.inv.SetState(n.ID, inventory.Ready)
readied = append(readied, n.ID)
}
}
f.log.Info("domain released", "domain", d, "readied", readied)
return h, readied, nil
}
// SetLabels sets and removes labels on a node through the control plane,
// over what its agent reports, and answers the operator's labels as they
// now are. A node not registered yet takes them when it does.
func (f *Facility) SetLabels(ctx context.Context, node string, set map[string]string, remove []string, by string) (map[string]string, error) {
for k, v := range set {
if err := checkLabel(k, v); err != nil {
return nil, err
}
}
f.mu.Lock()
defer f.mu.Unlock()
now := f.inv.OperatorLabels(node)
if now == nil {
now = map[string]string{}
}
if f.st != nil {
for _, k := range slices.Sorted(maps.Keys(set)) {
if err := f.st.SetNodeLabel(ctx, node, k, set[k], by); err != nil {
return nil, fmt.Errorf("keeping label %s: %w", k, err)
}
}
for _, k := range remove {
if err := f.st.SetNodeLabel(ctx, node, k, "", by); err != nil {
return nil, fmt.Errorf("removing label %s: %w", k, err)
}
}
}
maps.Copy(now, set)
for _, k := range remove {
delete(now, k)
}
f.inv.SetOperatorLabels(node, now)
f.log.Info("node labels set", "node", node, "set", set, "removed", remove, "by", by)
return now, nil
}
// checkLabel refuses a key no selector could match, and a misspelt location:
// llmgw.io/rak would be kept, shown, and never read by placement.
func checkLabel(k, v string) error {
switch {
case strings.TrimSpace(k) == "" || strings.ContainsAny(k, " =\t\n"):
return fmt.Errorf("label key %q is empty or has a space or = in it", k)
case strings.TrimSpace(v) == "":
return fmt.Errorf("label %s has no value; remove it with %s- instead", k, k)
case strings.HasPrefix(k, facility.LabelPrefix):
if _, err := facility.ParseLevel(strings.TrimPrefix(k, facility.LabelPrefix)); err != nil {
return fmt.Errorf("label %s: %s is reserved for where a node is: llmgw.io/site, room, row, rack or pdu", k, facility.LabelPrefix)
}
}
return nil
}