Files
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

180 lines
5.4 KiB
Go

package adminapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// SettingsStore keeps the cluster's settings.
type SettingsStore interface {
ClusterSettings(ctx context.Context) (store.SettingsVersion, error)
ClusterSettingsAt(ctx context.Context, version int64) (store.SettingsVersion, error)
ClusterSettingsHistory(ctx context.Context, limit int) ([]store.SettingsVersion, error)
PutClusterSettings(ctx context.Context, base int64, body, by, note string) (int64, error)
}
// Settings is what the settings endpoints need: where they are kept, and a
// check that a document would load — laid over this control plane's own
// server.yaml, validated as it would be at start.
type Settings struct {
Store SettingsStore
Check func(body []byte) error
// LocalKeys are those each machine's server.yaml keeps, which the
// document cannot set.
LocalKeys []string
}
// SetSettings enables the settings endpoints.
func (a *API) SetSettings(s *Settings) { a.settings = s }
// SettingsView is one version of the cluster's settings.
type SettingsView struct {
Version int64 `json:"version"`
Body string `json:"body"`
Note string `json:"note,omitempty"`
At time.Time `json:"at"`
By string `json:"by,omitempty"`
LocalKeys []string `json:"local_keys,omitempty"`
}
// SettingsRequest is a new version of the cluster's settings, made from the
// version Base, which must still be the newest.
type SettingsRequest struct {
Base int64 `json:"base"`
Body string `json:"body"`
Note string `json:"note,omitempty"`
}
// SettingsRollbackRequest puts an earlier version's settings back in force,
// as a new version.
type SettingsRollbackRequest struct {
Version int64 `json:"version"`
Note string `json:"note,omitempty"`
}
func settingsView(v store.SettingsVersion) SettingsView {
return SettingsView{Version: v.Version, Body: v.Body, Note: v.Note, At: v.At, By: v.By}
}
func (a *API) registerSettings(mux Mux) {
need := func(w http.ResponseWriter) bool {
if a.settings == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store to keep the cluster's settings in")
return false
}
return true
}
mux.HandleFunc("GET /api/v1/settings", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
var v store.SettingsVersion
var err error
if q := r.URL.Query().Get("version"); q != "" {
n, perr := strconv.ParseInt(q, 10, 64)
if perr != nil {
writeError(w, http.StatusBadRequest, "version is a number")
return
}
v, err = a.settings.Store.ClusterSettingsAt(r.Context(), n)
if err == nil && v.Version == 0 {
writeError(w, http.StatusNotFound, "there is no such version")
return
}
} else {
v, err = a.settings.Store.ClusterSettings(r.Context())
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := settingsView(v)
out.LocalKeys = a.settings.LocalKeys
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("GET /api/v1/settings/history", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
limit := 20
if q := r.URL.Query().Get("limit"); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 500 {
limit = n
}
}
list, err := a.settings.Store.ClusterSettingsHistory(r.Context(), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]SettingsView, len(list))
for i, v := range list {
out[i] = settingsView(v)
}
writeJSON(w, http.StatusOK, out)
})
put := func(w http.ResponseWriter, r *http.Request, base int64, body, note string) {
if err := a.settings.Check([]byte(body)); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
n, err := a.settings.Store.PutClusterSettings(r.Context(), base, body, actorOf(r), note)
switch {
case errors.Is(err, store.ErrSettingsChanged):
writeError(w, http.StatusConflict, err.Error())
return
case err != nil:
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v, err := a.settings.Store.ClusterSettingsAt(r.Context(), n)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, settingsView(v))
}
mux.HandleFunc("PUT /api/v1/settings", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
var req SettingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
put(w, r, req.Base, req.Body, req.Note)
})
mux.HandleFunc("POST /api/v1/settings/rollback", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
var req SettingsRollbackRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
old, err := a.settings.Store.ClusterSettingsAt(r.Context(), req.Version)
if err == nil && old.Version == 0 {
writeError(w, http.StatusNotFound, "there is no such version")
return
}
cur, err2 := a.settings.Store.ClusterSettings(r.Context())
if err = errors.Join(err, err2); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
note := req.Note
if note == "" {
note = "back to version " + strconv.FormatInt(req.Version, 10)
}
put(w, r, cur.Version, old.Body, note)
})
}