Files
llmgw/internal/settings/settings.go
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

612 lines
15 KiB
Go

// Package settings edits llmgw's configuration files key by key, for the
// settings screens: what each key is comes from the generated reference, so
// every setting the code reads is there without anyone listing it, and the
// file is edited as YAML nodes, so its comments and order survive.
package settings
import (
"bytes"
"fmt"
"os"
"reflect"
"strconv"
"strings"
"time"
"go.yaml.in/yaml/v3"
"gitea.mynodes.xyz/ilker/llmgw/internal/docgen"
)
// Kind is how a key is shown and edited.
type Kind int
// The kinds of key.
const (
Text Kind = iota
Int
Float
Bool
Duration
TextList // []string, []int: one value a line
TextMap // map[string]string and the like: key = value lines
Block // a nested block: a submenu
BlockList // a list of blocks: a submenu of entries
BlockMap // a map of blocks: a submenu of named entries
)
// Key is one key of a block, as the screens need it.
type Key struct {
docgen.Field
Kind Kind
// Block is the block it opens, for Block, BlockList and BlockMap.
Block *docgen.Block
}
// Blocks finds a block by ID in the generated reference.
func Find(id string) (*docgen.Block, bool) {
for i := range docgen.Reference {
if docgen.Reference[i].ID() == id {
return &docgen.Reference[i], true
}
}
return nil, false
}
// Keys are a block's keys with their kinds.
func Keys(b *docgen.Block) []Key {
out := make([]Key, 0, len(b.Fields))
for _, f := range b.Fields {
k := Key{Field: f}
k.Kind, k.Block = kindOf(b.Package, f)
out = append(out, k)
}
return out
}
func kindOf(pkg string, f docgen.Field) (Kind, *docgen.Block) {
if nb, ok := Find(f.Nested); ok {
return Block, nb
}
t := strings.TrimPrefix(f.Type, "*")
switch t {
case "string":
return Text, nil
case "int", "int64", "uint", "uint32":
return Int, nil
case "float64", "float32":
return Float, nil
case "bool":
return Bool, nil
case "time.Duration":
return Duration, nil
}
if elem, ok := strings.CutPrefix(t, "[]"); ok {
if b, ok := Find(qualify(pkg, elem)); ok {
return BlockList, b
}
return TextList, nil
}
if strings.HasPrefix(t, "map[") {
elem := t[strings.Index(t, "]")+1:]
if b, ok := Find(qualify(pkg, elem)); ok {
return BlockMap, b
}
return TextMap, nil
}
return Text, nil
}
// qualify is a type as a block ID: Endpoint in config is config.Endpoint.
func qualify(pkg, typ string) string {
if strings.Contains(typ, ".") {
return typ
}
return pkg + "." + typ
}
// Root is the block a file is: server.yaml is config.Server, agent.yaml the
// agent's Config.
func Root(file string) (*docgen.Block, error) {
id := "config.Server"
if strings.Contains(file, "agent") {
id = "agent.Config"
}
b, ok := Find(id)
if !ok {
return nil, fmt.Errorf("no block %s in the reference", id)
}
return b, nil
}
// Doc is a configuration file, open for editing.
type Doc struct {
Path string
root *yaml.Node // the document's top mapping
doc yaml.Node
// Changed counts edits not yet saved.
Changed int
// cluster holds the keys that are not the machine's own, when the
// cluster keeps them elsewhere (the store); isLocal says which are.
cluster *Doc
isLocal func([]string) bool
}
// Layer has the keys isLocal does not claim read and written in cluster
// instead of this document: one screen over a machine's file and the
// cluster's settings.
func (d *Doc) Layer(cluster *Doc, isLocal func([]string) bool) {
d.cluster, d.isLocal = cluster, isLocal
}
// Cluster is the layered cluster document, nil when there is none.
func (d *Doc) Cluster() *Doc { return d.cluster }
// Pending counts edits not yet saved, in this document and its layer.
func (d *Doc) Pending() int {
if d.cluster != nil {
return d.Changed + d.cluster.Changed
}
return d.Changed
}
// at is the document a path is kept in.
func (d *Doc) at(path []string) *Doc {
if d.cluster != nil && len(path) > 0 && !d.isLocal(path) {
return d.cluster
}
return d
}
// Open reads a file; a missing one is an empty document.
func Open(path string) (*Doc, error) {
b, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return nil, err
}
return parseDoc(path, b)
}
// FromBytes reads a document held somewhere other than a file: the
// cluster's settings, kept in the store. It has no Path, so it is not Saved.
func FromBytes(name string, b []byte) (*Doc, error) {
d, err := parseDoc(name, b)
if err != nil {
return nil, err
}
d.Path = ""
return d, nil
}
func parseDoc(path string, b []byte) (*Doc, error) {
d := &Doc{Path: path}
if len(bytes.TrimSpace(b)) > 0 {
if err := yaml.Unmarshal(b, &d.doc); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
}
if d.doc.Kind == 0 {
d.doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}}
}
d.root = d.doc.Content[0]
if d.root.Kind != yaml.MappingNode {
return nil, fmt.Errorf("%s is not a mapping at the top", path)
}
return d, nil
}
// Bytes is the document as it would be written.
func (d *Doc) Bytes() ([]byte, error) {
var b bytes.Buffer
enc := yaml.NewEncoder(&b)
enc.SetIndent(2)
if err := enc.Encode(&d.doc); err != nil {
return nil, err
}
return b.Bytes(), enc.Close()
}
// Node is the node at a path of keys and list indexes, or nil.
func (d *Doc) Node(path []string) *yaml.Node {
if t := d.at(path); t != d {
return t.Node(path)
}
n := d.root
for _, p := range path {
n = child(n, p)
if n == nil {
return nil
}
}
return n
}
func child(n *yaml.Node, key string) *yaml.Node {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
if n.Content[i].Value == key {
return n.Content[i+1]
}
}
case yaml.SequenceNode:
if i, err := strconv.Atoi(key); err == nil && i >= 0 && i < len(n.Content) {
return n.Content[i]
}
}
return nil
}
// ensure is the node at a path, made as mappings where there is none.
func (d *Doc) ensure(path []string) *yaml.Node {
n := d.root
for _, p := range path {
c := child(n, p)
if c == nil {
if n.Kind != yaml.MappingNode {
return nil
}
c = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: p}, c)
}
n = c
}
return n
}
// Set writes one key's value: a scalar, a list of lines or a map of
// "key = value" lines, by its kind. It checks the value first.
func (d *Doc) Set(path []string, k Kind, value string) error {
if t := d.at(path); t != d {
return t.Set(path, k, value)
}
if len(path) == 0 {
return fmt.Errorf("no key")
}
n, err := encode(k, value)
if err != nil {
return err
}
return d.place(path, n)
}
// SetYAML writes one key's value given as YAML: a scalar, [a, b], {k: v},
// or a whole block.
func (d *Doc) SetYAML(path []string, value string) error {
if t := d.at(path); t != d {
return t.SetYAML(path, value)
}
if len(path) == 0 {
return fmt.Errorf("no key")
}
var doc yaml.Node
if err := yaml.Unmarshal([]byte(value), &doc); err != nil {
return fmt.Errorf("%q is not YAML: %w", value, err)
}
n := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: ""}
if len(doc.Content) > 0 {
n = doc.Content[0]
}
return d.place(path, n)
}
// SetNode writes a node, as read from another document, at path.
func (d *Doc) SetNode(path []string, n *yaml.Node) error {
if t := d.at(path); t != d {
return t.SetNode(path, n)
}
if len(path) == 0 {
return fmt.Errorf("no key")
}
c := *n
return d.place(path, &c)
}
func (d *Doc) place(path []string, n *yaml.Node) error {
parent := d.ensure(path[:len(path)-1])
if parent == nil {
return fmt.Errorf("%s is not a block", strings.Join(path[:len(path)-1], "."))
}
key := path[len(path)-1]
if parent.Kind == yaml.SequenceNode {
i, _ := strconv.Atoi(key)
parent.Content[i] = n
} else if c := child(parent, key); c != nil {
// Kept in place, with whatever comment it had.
n.HeadComment, n.LineComment, n.FootComment = c.HeadComment, c.LineComment, c.FootComment
*c = *n
} else {
parent.Content = append(parent.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, n)
}
d.Changed++
return nil
}
// Unset removes a key, so its default applies.
func (d *Doc) Unset(path []string) {
if t := d.at(path); t != d {
t.Unset(path)
return
}
if len(path) == 0 {
return
}
parent := d.Node(path[:len(path)-1])
if parent == nil {
return
}
key := path[len(path)-1]
switch parent.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(parent.Content); i += 2 {
if parent.Content[i].Value == key {
parent.Content = append(parent.Content[:i], parent.Content[i+2:]...)
d.Changed++
return
}
}
case yaml.SequenceNode:
if i, err := strconv.Atoi(key); err == nil && i < len(parent.Content) {
parent.Content = append(parent.Content[:i], parent.Content[i+1:]...)
d.Changed++
}
}
}
// Append adds an empty block to the list at path, and returns its index.
func (d *Doc) Append(path []string) int {
if t := d.at(path); t != d {
return t.Append(path)
}
parent := d.ensure(path[:len(path)-1])
key := path[len(path)-1]
seq := child(parent, key)
if seq == nil || seq.Kind != yaml.SequenceNode {
seq = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
if c := child(parent, key); c != nil {
*c = *seq
seq = c
} else {
parent.Content = append(parent.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, seq)
}
}
seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"})
d.Changed++
return len(seq.Content) - 1
}
// AddEntry adds an empty block under a name to the map at path.
func (d *Doc) AddEntry(path []string, name string) error {
if t := d.at(path); t != d {
return t.AddEntry(path, name)
}
m := d.ensure(path)
if m == nil || m.Kind != yaml.MappingNode {
return fmt.Errorf("%s is not a map", strings.Join(path, "."))
}
if child(m, name) != nil {
return fmt.Errorf("%s is there already", name)
}
m.Content = append(m.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: name},
&yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"})
d.Changed++
return nil
}
// Len is how many entries a list or map at path has.
func (d *Doc) Len(path []string) int {
n := d.Node(path)
switch {
case n == nil:
return 0
case n.Kind == yaml.SequenceNode:
return len(n.Content)
case n.Kind == yaml.MappingNode:
return len(n.Content) / 2
}
return 0
}
// MapKeys are a map's keys, in the file's order.
func (d *Doc) MapKeys(path []string) []string {
n := d.Node(path)
if n == nil || n.Kind != yaml.MappingNode {
return nil
}
var out []string
for i := 0; i+1 < len(n.Content); i += 2 {
out = append(out, n.Content[i].Value)
}
return out
}
// Text is a key's value as it is edited: a scalar as written, a list one
// item a line, a map "key = value" a line. ok is false for a key the file
// does not set.
func (d *Doc) Text(path []string) (string, bool) {
n := d.Node(path)
if n == nil {
return "", false
}
switch n.Kind {
case yaml.ScalarNode:
return n.Value, true
case yaml.SequenceNode:
var l []string
for _, c := range n.Content {
l = append(l, c.Value)
}
return strings.Join(l, "\n"), true
case yaml.MappingNode:
var l []string
for i := 0; i+1 < len(n.Content); i += 2 {
l = append(l, n.Content[i].Value+" = "+n.Content[i+1].Value)
}
return strings.Join(l, "\n"), true
}
return "", true
}
func encode(k Kind, value string) (*yaml.Node, error) {
v := strings.TrimSpace(value)
scalar := func(tag string) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Tag: tag, Value: v} }
switch k {
case Int:
if _, err := strconv.ParseInt(v, 10, 64); err != nil {
return nil, fmt.Errorf("%q is not a whole number", v)
}
return scalar("!!int"), nil
case Float:
if _, err := strconv.ParseFloat(v, 64); err != nil {
return nil, fmt.Errorf("%q is not a number", v)
}
return scalar("!!float"), nil
case Bool:
b, err := strconv.ParseBool(v)
if err != nil {
return nil, fmt.Errorf("%q is not true or false", v)
}
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(b)}, nil
case Duration:
if _, err := time.ParseDuration(v); err != nil {
return nil, fmt.Errorf("%q is not a duration, like 30s, 5m or 2h", v)
}
return scalar("!!str"), nil
case TextList:
seq := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
for _, l := range splitLines(value) {
seq.Content = append(seq.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: l})
}
return seq, nil
case TextMap:
m := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
for _, l := range splitLines(value) {
k, val, ok := strings.Cut(l, "=")
if !ok {
return nil, fmt.Errorf("%q is not key = value", l)
}
m.Content = append(m.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: strings.TrimSpace(k)},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: strings.TrimSpace(val)})
}
return m, nil
case Block, BlockList, BlockMap:
return nil, fmt.Errorf("a block is edited key by key")
}
return scalar("!!str"), nil
}
func splitLines(s string) []string {
var out []string
for _, l := range strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == ',' }) {
if l = strings.TrimSpace(l); l != "" {
out = append(out, l)
}
}
return out
}
// Save checks the document with check — a loader that reads and validates
// a file — and writes it, the old file kept beside it. Nothing is written
// when the check fails.
func (d *Doc) Save(check func(path string) error) (string, error) {
b, err := d.Bytes()
if err != nil {
return "", err
}
tmp := d.Path + ".check"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return "", err
}
defer os.Remove(tmp)
if check != nil {
if err := check(tmp); err != nil {
return "", err
}
}
mode := os.FileMode(0o644)
backup := ""
if info, err := os.Stat(d.Path); err == nil {
mode = info.Mode().Perm()
old, err := os.ReadFile(d.Path)
if err != nil {
return "", err
}
backup = fmt.Sprintf("%s.bak-%s", d.Path, time.Now().Format("20060102-150405"))
if err := os.WriteFile(backup, old, mode); err != nil {
return "", err
}
}
if err := os.WriteFile(d.Path+".new", b, mode); err != nil {
return "", err
}
if err := os.Rename(d.Path+".new", d.Path); err != nil {
return "", err
}
d.Changed = 0
return backup, nil
}
// Effective is the value a loaded configuration has at a path of koanf
// keys — what applies, default included — as text.
func Effective(cfg any, path []string) (string, bool) {
v := reflect.ValueOf(cfg)
for _, p := range path {
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return "", false
}
v = v.Elem()
}
switch v.Kind() {
case reflect.Struct:
found := false
t := v.Type()
for i := 0; i < t.NumField(); i++ {
if tag, _, _ := strings.Cut(t.Field(i).Tag.Get("koanf"), ","); tag == p {
v, found = v.Field(i), true
break
}
}
if !found {
return "", false
}
case reflect.Slice:
i, err := strconv.Atoi(p)
if err != nil || i >= v.Len() {
return "", false
}
v = v.Index(i)
case reflect.Map:
mv := v.MapIndex(reflect.ValueOf(p))
if !mv.IsValid() {
return "", false
}
v = mv
default:
return "", false
}
}
for v.Kind() == reflect.Pointer {
if v.IsNil() {
return "", true
}
v = v.Elem()
}
switch v.Kind() {
case reflect.Slice:
var l []string
for i := 0; i < v.Len(); i++ {
l = append(l, fmt.Sprint(v.Index(i).Interface()))
}
return strings.Join(l, ", "), true
case reflect.Struct, reflect.Map:
return "", true
}
if d, ok := v.Interface().(time.Duration); ok {
return d.String(), true
}
return fmt.Sprint(v.Interface()), true
}