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>
142 lines
4.0 KiB
Go
142 lines
4.0 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
|
)
|
|
|
|
// EngineChoiceStore is where the engine builds chosen per node are kept.
|
|
type EngineChoiceStore interface {
|
|
NodeEngines(ctx context.Context) (map[string]map[string]string, error)
|
|
SetNodeEngine(ctx context.Context, node, engine, variant, by string) error
|
|
}
|
|
|
|
// EngineChoices are the engine builds chosen for nodes, rather than left to
|
|
// their hardware: vLLM's CUDA 12.9 build on a node its CUDA 13 build would
|
|
// also install on, or no vLLM at all. Kept in the store, held here for the
|
|
// agent service to ask on every registration, and pushed to a node the
|
|
// moment its choice changes.
|
|
type EngineChoices struct {
|
|
st EngineChoiceStore
|
|
log *slog.Logger
|
|
|
|
mu sync.RWMutex
|
|
byNode map[string]map[string]string
|
|
notify func(node string) error
|
|
}
|
|
|
|
// NewEngineChoices reads what was chosen before.
|
|
func NewEngineChoices(ctx context.Context, st EngineChoiceStore, log *slog.Logger) *EngineChoices {
|
|
c := &EngineChoices{st: st, log: log, byNode: map[string]map[string]string{}}
|
|
if got, err := st.NodeEngines(ctx); err != nil {
|
|
log.Error("could not read the engine builds chosen per node; every node installs what its hardware runs", "err", err)
|
|
} else {
|
|
c.byNode = got
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Reload reads the choices again, as a standby becoming the leader does, and
|
|
// tells each node whose choice changed.
|
|
func (c *EngineChoices) Reload(ctx context.Context) {
|
|
got, err := c.st.NodeEngines(ctx)
|
|
if err != nil {
|
|
c.log.Error("could not read the engine builds chosen per node again; keeping the ones read before", "err", err)
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
var changed []string
|
|
for node := range got {
|
|
if !maps.Equal(got[node], c.byNode[node]) {
|
|
changed = append(changed, node)
|
|
}
|
|
}
|
|
for node := range c.byNode {
|
|
if _, ok := got[node]; !ok {
|
|
changed = append(changed, node)
|
|
}
|
|
}
|
|
c.byNode = got
|
|
notify := c.notify
|
|
c.mu.Unlock()
|
|
if notify == nil {
|
|
return
|
|
}
|
|
for _, node := range changed {
|
|
if err := notify(node); err != nil {
|
|
c.log.Warn("could not tell a node its engine builds", "node", node, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// OnChange is called with a node whose choice changed, to tell it.
|
|
func (c *EngineChoices) OnChange(f func(node string) error) {
|
|
c.mu.Lock()
|
|
c.notify = f
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// For is what was chosen for one node, by engine: a build, or "none".
|
|
func (c *EngineChoices) For(node string) map[string]string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return maps.Clone(c.byNode[node])
|
|
}
|
|
|
|
// EngineBuilds are what can be chosen for each engine that has a choice:
|
|
// "auto" is the node's hardware deciding, "none" no engine at all.
|
|
func EngineBuilds(engine string) []string {
|
|
switch engine {
|
|
case "vllm":
|
|
return append(append([]string{"auto"}, provision.VLLMVariants...), "none")
|
|
case "sglang":
|
|
return []string{"auto", "none"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Set chooses an engine's build for a node, keeps it, and tells the node.
|
|
func (c *EngineChoices) Set(ctx context.Context, node, engine, build, by string) error {
|
|
builds := EngineBuilds(engine)
|
|
if builds == nil {
|
|
return fmt.Errorf("%s has no builds to choose between", engine)
|
|
}
|
|
if !slices.Contains(builds, build) {
|
|
return fmt.Errorf("%s has no build %q; choose %s", engine, build, strings.Join(builds, ", "))
|
|
}
|
|
variant := build
|
|
if build == "auto" {
|
|
variant = ""
|
|
}
|
|
if err := c.st.SetNodeEngine(ctx, node, engine, variant, by); err != nil {
|
|
return err
|
|
}
|
|
c.mu.Lock()
|
|
if variant == "" {
|
|
delete(c.byNode[node], engine)
|
|
} else {
|
|
if c.byNode[node] == nil {
|
|
c.byNode[node] = map[string]string{}
|
|
}
|
|
c.byNode[node][engine] = variant
|
|
}
|
|
notify := c.notify
|
|
c.mu.Unlock()
|
|
c.log.Info("engine build chosen for a node", "node", node, "engine", engine, "build", build, "by", by)
|
|
if notify != nil {
|
|
if err := notify(node); err != nil {
|
|
// Kept and applied on its next connection; said, since a node that
|
|
// is not connected is the usual reason.
|
|
c.log.Warn("the node was not told yet; it is when it next connects", "node", node, "err", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|