The leader adds the picture and 3D presets a node fits, unless told not to
A customer's portal had no Images or 3D pages: nobody had written a
definition for either, on a cluster whose V100 runs both. The leading
control plane now adds flux-schnell and triposr, on demand, once a node
has a card that fits one, the cluster installs ComfyUI, and the catalog
makes nothing of that kind. presets.auto (a live setting, on by default)
turns it off; a preset somebody removes is recorded as declined and not
added back until added again by hand. Each addition is in the audit log.
GET /api/v1/presets says how each preset stands, the ComfyUI the cluster
installs, and why one is not added; POST /api/v1/presets/{name} adds one
(setting engines.comfyui where there is none); PUT /api/v1/presets/auto
switches it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d817558121
commit
f42af27da5
18 files changed
+937
-5
No files matched your search
@@ -26,6 +26,18 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **Picture and 3D models by themselves**: once a node has a card that fits
|
||||
flux-schnell or triposr and the catalog makes nothing of that kind, the
|
||||
leading control plane adds the preset on demand — so a new cluster's
|
||||
portal has its Images and 3D pages without a definition written — where
|
||||
the cluster installs ComfyUI. `presets.auto` turns it off; a preset
|
||||
somebody removes is not added back. `GET /api/v1/presets` says how each
|
||||
stands and why one is not added; `POST /api/v1/presets/{name}` adds one
|
||||
(and sets `engines.comfyui` if the cluster has none); `PUT
|
||||
/api/v1/presets/auto`. Each automatic addition is in the audit log.
|
||||
|
||||
## v0.14.4 — 2026-10-13
|
||||
|
||||
Any ComfyUI workflow as a job, with typed inputs; picture and 3D models
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
{
|
||||
"name": "plan"
|
||||
},
|
||||
{
|
||||
"name": "presets"
|
||||
},
|
||||
{
|
||||
"name": "projects"
|
||||
},
|
||||
@@ -3651,6 +3654,116 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/presets": {
|
||||
"get": {
|
||||
"operationId": "getPresets",
|
||||
"summary": "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined",
|
||||
"tags": [
|
||||
"presets"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PresetsView"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/presets/auto": {
|
||||
"put": {
|
||||
"operationId": "putPresetsAuto",
|
||||
"summary": "Turn on or off the adding of presets when a node fits them",
|
||||
"tags": [
|
||||
"presets"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Actor"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PresetsAutoRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PresetsView"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/presets/{name}": {
|
||||
"post": {
|
||||
"operationId": "postPresetsName",
|
||||
"summary": "Add a preset to the catalog, and clear its decline",
|
||||
"tags": [
|
||||
"presets"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The preset's name, as `llmgw ctl model presets` lists it.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/parameters/Actor"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModelAdded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/projects": {
|
||||
"post": {
|
||||
"operationId": "postProjects",
|
||||
@@ -7481,6 +7594,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PresetView": {
|
||||
"type": "object",
|
||||
"description": "PresetView is one preset as it stands in this cluster.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"makes": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"card": {
|
||||
"type": "string"
|
||||
},
|
||||
"licence": {
|
||||
"$ref": "#/components/schemas/catalog.License"
|
||||
},
|
||||
"fits": {
|
||||
"type": "boolean",
|
||||
"description": "Fits is whether some node has a card that meets the preset's need; FitsClasses names those classes."
|
||||
},
|
||||
"fits_classes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"in_catalog": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"added_by": {
|
||||
"type": "string",
|
||||
"description": "AddedBy is \"auto\" when the leader added it, who did otherwise, \"\" when it was not added as a preset; AddedAt is when."
|
||||
},
|
||||
"added_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"declined": {
|
||||
"type": "boolean",
|
||||
"description": "Declined is whether somebody removed it after it was added, so it is not added again by itself; adding it again clears that."
|
||||
},
|
||||
"blocked": {
|
||||
"type": "string",
|
||||
"description": "Blocked is why the leader does not add it by itself — presets.auto off, no ComfyUI, no card that fits, declined, another model already making the same — empty when it would or has."
|
||||
}
|
||||
}
|
||||
},
|
||||
"PresetsAutoRequest": {
|
||||
"type": "object",
|
||||
"description": "PresetsAutoRequest turns the automatic adding of presets on or off.",
|
||||
"properties": {
|
||||
"on": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PresetsView": {
|
||||
"type": "object",
|
||||
"description": "PresetsView is the models that come with llmgw, and whether the leader adds them by itself.",
|
||||
"properties": {
|
||||
"auto": {
|
||||
"type": "boolean",
|
||||
"description": "Auto is whether a preset is added when a node fits it and the catalog has no model that makes what it makes (presets.auto)."
|
||||
},
|
||||
"comfyui": {
|
||||
"type": "string",
|
||||
"description": "ComfyUI is engines.comfyui as it stands: the version the nodes install, \"\" for none — in which case adding a preset sets it, and the control planes start again on that."
|
||||
},
|
||||
"presets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PresetView"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProjectView": {
|
||||
"type": "object",
|
||||
"description": "ProjectView is a project, with how many people and keys are in it.",
|
||||
|
||||
@@ -244,6 +244,7 @@ rather than repeated.
|
||||
| `match_ratio` | `float64` | [Prefix](#prefix) |
|
||||
| `balance_abs` | `float64` | [Prefix](#prefix) |
|
||||
| `balance_rel` | `float64` | [Prefix](#prefix) |
|
||||
| `auto` | `bool` | [PresetSettings](#presetsettings) |
|
||||
| `source` | `string` | [PreviousOrigin](#previousorigin) |
|
||||
| `revision` | `string` | [PreviousOrigin](#previousorigin) |
|
||||
| `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) |
|
||||
@@ -344,6 +345,7 @@ rather than repeated.
|
||||
| `mail` | `mail.Config` | [Server](#server) |
|
||||
| `serving` | `ServingSettings` | [Server](#server) |
|
||||
| `firewall` | `FirewallSettings` | [Server](#server) |
|
||||
| `presets` | `PresetSettings` | [Server](#server) |
|
||||
| `agents` | `AgentSettings` | [Server](#server) |
|
||||
| `firewall_networks` | `[]string` | [Server](#server) |
|
||||
| `backup_recipients` | `[]string` | [Server](#server) |
|
||||
@@ -2158,6 +2160,20 @@ before affinity is abandoned.
|
||||
|
||||
`float64`
|
||||
|
||||
## PresetSettings
|
||||
|
||||
### `auto`
|
||||
|
||||
`bool`
|
||||
|
||||
Auto has the leading control plane add a picture model (flux-schnell)
|
||||
and a 3D model (triposr) by itself, on demand, once a node has a card
|
||||
that fits one and the catalog has no model that makes the same kind of
|
||||
thing — so a new cluster's portal has its pictures and 3D pages
|
||||
without anybody writing a definition. Only where the cluster already
|
||||
installs ComfyUI (engines.comfyui). A preset somebody removed is not
|
||||
added again by itself. In force on the leader within a minute.
|
||||
|
||||
## PreviousOrigin
|
||||
|
||||
### `source`
|
||||
@@ -2960,6 +2976,14 @@ Firewall turns llmgw's firewall on every machine on or off.
|
||||
|
||||
Takes a [FirewallSettings](#firewallsettings) block.
|
||||
|
||||
### `presets`
|
||||
|
||||
`PresetSettings`
|
||||
|
||||
Presets is how the models that come with llmgw reach the catalog.
|
||||
|
||||
Takes a [PresetSettings](#presetsettings) block.
|
||||
|
||||
### `agents`
|
||||
|
||||
`AgentSettings`
|
||||
|
||||
@@ -243,6 +243,8 @@ var liveSettings = []string{
|
||||
"firewall", "firewall_networks",
|
||||
// Sent to the nodes' agents as they change.
|
||||
"agents",
|
||||
// Read by the leader's presets pass each time it runs.
|
||||
"presets",
|
||||
// Read by server init, node add and backups, not by the server.
|
||||
"backup_recipients", "backup_copy_to",
|
||||
}
|
||||
|
||||
@@ -465,6 +465,14 @@ func newServerCmd() *cobra.Command {
|
||||
}
|
||||
api.SetModels(svc)
|
||||
catalogSvc = svc
|
||||
// The models that come with llmgw: added by the leader
|
||||
// when a node fits one and the catalog makes nothing of
|
||||
// its kind, unless somebody removed it or turned it off.
|
||||
api.SetPresets(st, func() adminapi.PresetConfig {
|
||||
c := current.Load()
|
||||
return adminapi.PresetConfig{Auto: c.Presets.Auto, ComfyUI: c.Engines[catalog.EngineComfyUI]}
|
||||
})
|
||||
go runPresetsPass(ctx, api, admin.Leadership(), log)
|
||||
// And the mirror page, so a model that has just been
|
||||
// fetched can be served without writing YAML by hand.
|
||||
rt.SetCatalogAdmin(svc)
|
||||
@@ -1281,3 +1289,32 @@ func embedder(replay func(ctx context.Context, path, model string, body []byte)
|
||||
return res.Data[0].Embedding, nil
|
||||
}
|
||||
}
|
||||
|
||||
// presetsEvery is how often the leader looks for presets to add; the first
|
||||
// look waits as long, for the nodes to have registered their cards.
|
||||
const presetsEvery = time.Minute
|
||||
|
||||
// runPresetsPass has the leader add the presets a node fits and the catalog
|
||||
// lacks, now and then.
|
||||
func runPresetsPass(ctx context.Context, api *adminapi.API, lead *control.Leadership, log *slog.Logger) {
|
||||
t := time.NewTicker(presetsEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
if lead != nil && !lead.IsLeader() {
|
||||
continue
|
||||
}
|
||||
added, err := api.PresetsPass(ctx)
|
||||
for _, name := range added {
|
||||
log.Info("added a preset by itself: a node fits it and the catalog made nothing of its kind",
|
||||
"model", name, "off_switch", "presets.auto")
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn("could not add a preset", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,9 @@ type Server struct {
|
||||
// Firewall turns llmgw's firewall on every machine on or off.
|
||||
Firewall FirewallSettings `koanf:"firewall"`
|
||||
|
||||
// Presets is how the models that come with llmgw reach the catalog.
|
||||
Presets PresetSettings `koanf:"presets"`
|
||||
|
||||
// Agents are the nodes' agent settings: agent.yaml's keys but those an
|
||||
// agent needs to start, for every node (default) and for one
|
||||
// (nodes.<id>). Sent to the agents as they change; an agent whose
|
||||
@@ -795,6 +798,7 @@ func Default() Server {
|
||||
MaxConnections: 10_000,
|
||||
Mirror: Mirror{UpdateCheck: 6 * time.Hour},
|
||||
Firewall: FirewallSettings{Enabled: true},
|
||||
Presets: PresetSettings{Auto: true},
|
||||
Control: Control{
|
||||
Interval: 5 * time.Second,
|
||||
BaseBackoff: 10 * time.Second,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package config
|
||||
|
||||
// PresetSettings is how the models that come with llmgw reach the catalog.
|
||||
type PresetSettings struct {
|
||||
// Auto has the leading control plane add a picture model (flux-schnell)
|
||||
// and a 3D model (triposr) by itself, on demand, once a node has a card
|
||||
// that fits one and the catalog has no model that makes the same kind of
|
||||
// thing — so a new cluster's portal has its pictures and 3D pages
|
||||
// without anybody writing a definition. Only where the cluster already
|
||||
// installs ComfyUI (engines.comfyui). A preset somebody removed is not
|
||||
// added again by itself. In force on the leader within a minute.
|
||||
Auto bool `koanf:"auto"`
|
||||
}
|
||||
@@ -81,11 +81,15 @@ type API struct {
|
||||
// revoke records a removed node, so its certificate is refused.
|
||||
revoke func(ctx context.Context, nodeID string, at time.Time) error
|
||||
|
||||
models Models
|
||||
capacity Capacity
|
||||
usage Usage
|
||||
quotas Quotas
|
||||
ctl *control.Controller
|
||||
models Models
|
||||
// presets keeps what became of each preset, presetCfg reads the
|
||||
// settings in force; nil without a store.
|
||||
presets PresetStore
|
||||
presetCfg func() PresetConfig
|
||||
capacity Capacity
|
||||
usage Usage
|
||||
quotas Quotas
|
||||
ctl *control.Controller
|
||||
}
|
||||
|
||||
// New builds the API over a controller.
|
||||
@@ -166,6 +170,7 @@ func (a *API) Register(mux Mux) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
})
|
||||
a.registerPresets(mux)
|
||||
mux.HandleFunc("GET /api/v1/models", a.listModels)
|
||||
mux.HandleFunc("GET /api/v1/replicas", a.listReplicas)
|
||||
// Listing is a read and deleting is a separate verb, so a mistyped
|
||||
@@ -1229,6 +1234,8 @@ func (a *API) removeModel(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
// A preset removed is one the leader must not put back by itself.
|
||||
a.presetDeclined(r.Context(), name, actorOf(r))
|
||||
writeJSON(w, http.StatusOK, Removed{Removed: name})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// PresetsView is the models that come with llmgw, and whether the leader adds
|
||||
// them by itself.
|
||||
type PresetsView struct {
|
||||
// Auto is whether a preset is added when a node fits it and the catalog
|
||||
// has no model that makes what it makes (presets.auto).
|
||||
Auto bool `json:"auto"`
|
||||
// ComfyUI is engines.comfyui as it stands: the version the nodes
|
||||
// install, "" for none — in which case adding a preset sets it, and the
|
||||
// control planes start again on that.
|
||||
ComfyUI string `json:"comfyui"`
|
||||
Presets []PresetView `json:"presets"`
|
||||
}
|
||||
|
||||
// PresetView is one preset as it stands in this cluster.
|
||||
type PresetView struct {
|
||||
Name string `json:"name"`
|
||||
Makes string `json:"makes"`
|
||||
Description string `json:"description"`
|
||||
Card string `json:"card"`
|
||||
Licence catalog.License `json:"licence"`
|
||||
// Fits is whether some node has a card that meets the preset's need;
|
||||
// FitsClasses names those classes.
|
||||
Fits bool `json:"fits"`
|
||||
FitsClasses []string `json:"fits_classes,omitempty"`
|
||||
InCatalog bool `json:"in_catalog"`
|
||||
// AddedBy is "auto" when the leader added it, who did otherwise, ""
|
||||
// when it was not added as a preset; AddedAt is when.
|
||||
AddedBy string `json:"added_by,omitempty"`
|
||||
AddedAt time.Time `json:"added_at,omitzero"`
|
||||
// Declined is whether somebody removed it after it was added, so it is
|
||||
// not added again by itself; adding it again clears that.
|
||||
Declined bool `json:"declined"`
|
||||
// Blocked is why the leader does not add it by itself — presets.auto
|
||||
// off, no ComfyUI, no card that fits, declined, another model already
|
||||
// making the same — empty when it would or has.
|
||||
Blocked string `json:"blocked,omitempty"`
|
||||
}
|
||||
|
||||
// PresetsAutoRequest turns the automatic adding of presets on or off.
|
||||
type PresetsAutoRequest struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
|
||||
// errPresetsUnavailable is a control plane without the store presets are
|
||||
// kept in.
|
||||
var errPresetsUnavailable = errors.New("this control plane has no store, so presets cannot be added here")
|
||||
|
||||
// PresetStore keeps what became of each preset, and the audit log the
|
||||
// leader writes its own additions to.
|
||||
type PresetStore interface {
|
||||
PresetStates(ctx context.Context) (map[string]store.PresetState, error)
|
||||
PresetAdded(ctx context.Context, name, by string, at time.Time) error
|
||||
PresetDeclined(ctx context.Context, name, by string, at time.Time) error
|
||||
WriteAudit(ctx context.Context, e store.AuditEvent) error
|
||||
}
|
||||
|
||||
// PresetConfig is what the presets read of the settings in force.
|
||||
type PresetConfig struct {
|
||||
// Auto is presets.auto.
|
||||
Auto bool
|
||||
// ComfyUI is engines.comfyui: the version the nodes install, empty for
|
||||
// none.
|
||||
ComfyUI string
|
||||
}
|
||||
|
||||
// SetPresets enables the presets: where what became of them is kept, and
|
||||
// the settings in force.
|
||||
func (a *API) SetPresets(st PresetStore, cfg func() PresetConfig) {
|
||||
a.presets, a.presetCfg = st, cfg
|
||||
}
|
||||
|
||||
// Presets is the presets as they stand in this cluster.
|
||||
func (a *API) Presets(ctx context.Context) (PresetsView, error) {
|
||||
if a.presets == nil || a.presetCfg == nil {
|
||||
return PresetsView{}, errPresetsUnavailable
|
||||
}
|
||||
states, err := a.presets.PresetStates(ctx)
|
||||
if err != nil {
|
||||
return PresetsView{}, err
|
||||
}
|
||||
cfg := a.presetCfg()
|
||||
cat := a.ctl.Catalog()
|
||||
kinds := a.gpuKinds()
|
||||
out := PresetsView{Auto: cfg.Auto, ComfyUI: cfg.ComfyUI}
|
||||
for _, p := range catalog.Presets() {
|
||||
v := PresetView{Name: p.Model.Name, Makes: p.Makes, Description: p.Model.Description,
|
||||
Card: presetCard(p.Model), FitsClasses: presetFits(p.Model, kinds)}
|
||||
if p.Model.License != nil {
|
||||
v.Licence = *p.Model.License
|
||||
}
|
||||
v.Fits = len(v.FitsClasses) > 0
|
||||
_, v.InCatalog = cat.Resolve(p.Model.Name)
|
||||
if st, ok := states[p.Model.Name]; ok {
|
||||
v.AddedBy, v.AddedAt, v.Declined = st.AddedBy, st.AddedAt, st.Declined
|
||||
}
|
||||
v.Blocked = presetBlocked(p, v, cfg, cat)
|
||||
out.Presets = append(out.Presets, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddPreset adds the preset of that name, and clears its decline. A cluster
|
||||
// that installs no ComfyUI yet is set to (engines.comfyui), as `llmgw ctl
|
||||
// model add` does; that change starts the control planes again.
|
||||
func (a *API) AddPreset(ctx context.Context, name, by string) error {
|
||||
if a.presets == nil || a.presetCfg == nil || a.models == nil {
|
||||
return errPresetsUnavailable
|
||||
}
|
||||
p, ok := catalog.PresetNamed(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %q (there are %s)", errNoSuchPreset, name, strings.Join(catalog.PresetNames(), ", "))
|
||||
}
|
||||
if _, in := a.ctl.Catalog().Resolve(name); in {
|
||||
return fmt.Errorf("%s is already in the catalog", name)
|
||||
}
|
||||
if a.presetCfg().ComfyUI == "" {
|
||||
if err := a.setComfyUI(ctx, by); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := a.models.Add(ctx, p.Model, by); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.presets.PresetAdded(ctx, name, by, time.Now())
|
||||
}
|
||||
|
||||
// SetAutoPresets turns presets.auto on or off.
|
||||
func (a *API) SetAutoPresets(ctx context.Context, on bool, by string) error {
|
||||
if a.presets == nil {
|
||||
return errPresetsUnavailable
|
||||
}
|
||||
note := "presets added by themselves: " + map[bool]string{true: "on", false: "off"}[on]
|
||||
return a.editSettings(ctx, []string{"presets", "auto"}, strconv.FormatBool(on), by, note)
|
||||
}
|
||||
|
||||
// PresetsPass adds, as "auto", each preset a node fits whose kind of model
|
||||
// the catalog has none of: run by the leader, now and then. It adds nothing
|
||||
// with presets.auto off, where the cluster installs no ComfyUI, or a preset
|
||||
// somebody declined. It says what it added.
|
||||
func (a *API) PresetsPass(ctx context.Context) ([]string, error) {
|
||||
v, err := a.Presets(ctx)
|
||||
if err != nil || !v.Auto {
|
||||
return nil, err
|
||||
}
|
||||
var added []string
|
||||
for _, pv := range v.Presets {
|
||||
if pv.Blocked != "" || pv.InCatalog {
|
||||
continue
|
||||
}
|
||||
p, _ := catalog.PresetNamed(pv.Name)
|
||||
if err := a.models.Add(ctx, p.Model, store.PresetAdder); err != nil {
|
||||
return added, fmt.Errorf("adding %s: %w", pv.Name, err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := a.presets.PresetAdded(ctx, pv.Name, store.PresetAdder, now); err != nil {
|
||||
return added, err
|
||||
}
|
||||
_ = a.presets.WriteAudit(ctx, store.AuditEvent{At: now, Actor: store.PresetAdder, Source: "presets",
|
||||
Action: "add preset", Target: pv.Name, Status: http.StatusOK,
|
||||
Detail: fmt.Sprintf("%s, on demand: a node has %s (%s) and the catalog made no %s",
|
||||
pv.Name, strings.Join(pv.FitsClasses, ", "), pv.Card, pv.Makes)})
|
||||
added = append(added, pv.Name)
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// presetDeclined records a preset's name removed from the catalog, so the
|
||||
// leader does not put it back.
|
||||
func (a *API) presetDeclined(ctx context.Context, name, by string) {
|
||||
if a.presets == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := catalog.PresetNamed(name); ok {
|
||||
_ = a.presets.PresetDeclined(ctx, name, by, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// presetBlocked is why the leader would not add a preset by itself, empty
|
||||
// when it would (or has).
|
||||
func presetBlocked(p catalog.Preset, v PresetView, cfg PresetConfig, cat *catalog.Catalog) string {
|
||||
switch {
|
||||
case v.InCatalog:
|
||||
return ""
|
||||
case v.Declined:
|
||||
return "removed from the catalog after it was added; add it again to undo"
|
||||
case !cfg.Auto:
|
||||
return "presets.auto is off"
|
||||
case cfg.ComfyUI == "":
|
||||
return "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"
|
||||
case !v.Fits:
|
||||
return "no node has a card it needs (" + v.Card + ")"
|
||||
}
|
||||
for _, m := range cat.Models() {
|
||||
if p.Makes == catalog.OutputPictures && m.MakesImages() || p.Makes == catalog.OutputMeshes && m.MakesMeshes() {
|
||||
return "the catalog has " + m.Name + ", which makes " + p.Makes + " already"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// presetCard is what a preset needs of a card, as people read it.
|
||||
func presetCard(m catalog.Model) string {
|
||||
var needs []string
|
||||
for _, pr := range m.Profiles {
|
||||
switch {
|
||||
case pr.GPU != nil:
|
||||
needs = append(needs, pr.GPU.String())
|
||||
case pr.GPUClass != "":
|
||||
needs = append(needs, pr.GPUClass)
|
||||
}
|
||||
}
|
||||
return strings.Join(needs, " or ")
|
||||
}
|
||||
|
||||
// presetFits are the classes of card in the cluster one of the preset's
|
||||
// profiles can run on.
|
||||
func presetFits(m catalog.Model, kinds []catalog.GPUKind) []string {
|
||||
var out []string
|
||||
for _, k := range kinds {
|
||||
for _, pr := range m.Profiles {
|
||||
if pr.GPU != nil && pr.GPU.Fits(k) || pr.GPU == nil && pr.GPUClass == k.Class {
|
||||
out = append(out, k.Class)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gpuKinds are the kinds of card the cluster's nodes have.
|
||||
func (a *API) gpuKinds() []catalog.GPUKind {
|
||||
var gpus []discovery.GPU
|
||||
for _, n := range a.ctl.Nodes() {
|
||||
gpus = append(gpus, n.GPUs...)
|
||||
}
|
||||
return catalog.GPUKinds(gpus)
|
||||
}
|
||||
|
||||
// setComfyUI has the nodes install the ComfyUI this release knows.
|
||||
func (a *API) setComfyUI(ctx context.Context, by string) error {
|
||||
if a.settings != nil {
|
||||
cur, err := a.settings.Store.ClusterSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if doc, err := settings.FromBytes("settings", []byte(cur.Body)); err == nil {
|
||||
if n := doc.Node([]string{"engines", catalog.EngineComfyUI}); n != nil && n.Value == "" {
|
||||
return errors.New("engines.comfyui is set to none, so the nodes install no ComfyUI: " +
|
||||
"set a version (llmgw ctl config set engines.comfyui " + provision.ComfyUIVersion() + ")")
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.editSettings(ctx, []string{"engines", catalog.EngineComfyUI}, provision.ComfyUIVersion(), by,
|
||||
"ComfyUI for the presets")
|
||||
}
|
||||
|
||||
// editSettings sets one key of the cluster's settings, as a new version.
|
||||
func (a *API) editSettings(ctx context.Context, path []string, value, by, note string) error {
|
||||
if a.settings == nil {
|
||||
return errors.New("this control plane keeps no cluster settings")
|
||||
}
|
||||
cur, err := a.settings.Store.ClusterSettings(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := settings.FromBytes("settings", []byte(cur.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n := doc.Node(path); n != nil && n.Value == value {
|
||||
return nil
|
||||
}
|
||||
if err := doc.SetYAML(path, value); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := doc.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.settings.Check(b); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.settings.Store.PutClusterSettings(ctx, cur.Version, string(b), by, note)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *API) registerPresets(mux Mux) {
|
||||
mux.HandleFunc("GET /api/v1/presets", func(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := a.Presets(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, presetStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/presets/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.AddPreset(r.Context(), r.PathValue("name"), actorOf(r)); err != nil {
|
||||
writeError(w, presetStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ModelAdded{Added: r.PathValue("name")})
|
||||
})
|
||||
mux.HandleFunc("PUT /api/v1/presets/auto", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req PresetsAutoRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.SetAutoPresets(r.Context(), req.On, actorOf(r)); err != nil {
|
||||
writeError(w, presetStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
v, err := a.Presets(r.Context())
|
||||
if err != nil {
|
||||
writeError(w, presetStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
})
|
||||
}
|
||||
|
||||
// errNoSuchPreset is a name no preset has.
|
||||
var errNoSuchPreset = errors.New("no preset of that name")
|
||||
|
||||
func presetStatus(err error) int {
|
||||
switch {
|
||||
case errors.Is(err, errPresetsUnavailable):
|
||||
return http.StatusNotImplemented
|
||||
case errors.Is(err, errNoSuchPreset):
|
||||
return http.StatusNotFound
|
||||
default:
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func v100Node(id string) inventory.Node {
|
||||
return inventory.Node{ID: id, State: inventory.Ready, AgentVersion: "v1", GPUs: []discovery.GPU{{
|
||||
Index: 0, Name: "Tesla V100-SXM2-32GB", MemoryTotalMB: 32768, ComputeCapability: "7.0"}}}
|
||||
}
|
||||
|
||||
func presetsAPI(t *testing.T, models []catalog.Model, cfg PresetConfig, nodes ...inventory.Node) (*API, *fakeModels, *store.DB) {
|
||||
t.Helper()
|
||||
a, _ := setup(t, models, nodes...)
|
||||
st, err := store.OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "p.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
fm := &fakeModels{}
|
||||
a.SetModels(fm)
|
||||
a.SetPresets(st, func() PresetConfig { return cfg })
|
||||
a.SetSettings(&Settings{Store: st, Check: func([]byte) error { return nil }})
|
||||
return a, fm, st
|
||||
}
|
||||
|
||||
func TestTheLeaderAddsThePresetsANodeFitsAndNoOtherWhere(t *testing.T) {
|
||||
// A customer's portal had no pictures or 3D pages: nobody had written a
|
||||
// definition for either, on a cluster with a V100 that runs both.
|
||||
a, fm, st := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, v100Node("p-g3"))
|
||||
added, err := a.PresetsPass(t.Context())
|
||||
if err != nil || !slices.Equal(added, []string{"flux-schnell", "triposr"}) {
|
||||
t.Fatalf("added %v %v", added, err)
|
||||
}
|
||||
if !slices.Equal(fm.actors, []string{store.PresetAdder, store.PresetAdder}) {
|
||||
t.Errorf("added by %v", fm.actors)
|
||||
}
|
||||
states, _ := st.PresetStates(t.Context())
|
||||
if states["triposr"].AddedBy != store.PresetAdder || states["triposr"].AddedAt.IsZero() {
|
||||
t.Errorf("state %+v", states["triposr"])
|
||||
}
|
||||
events, _ := st.AuditEvents(t.Context(), store.AuditFilter{Limit: 10})
|
||||
if len(events) != 2 || events[0].Actor != store.PresetAdder || !strings.Contains(events[0].Detail, "v100-32g") {
|
||||
t.Errorf("audit %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPresetIsNotAddedByItselfWhereItShouldNotBe(t *testing.T) {
|
||||
picture := catalog.Model{Name: "sdxl", Source: "/w/sdxl.safetensors", ComfyUI: &catalog.ComfyUI{Workflow: "flux-schnell"},
|
||||
Profiles: []catalog.Profile{{Name: "p", Engine: catalog.EngineComfyUI, GPUClass: "v100-32g", TP: 1}}}
|
||||
cases := []struct {
|
||||
name string
|
||||
models []catalog.Model
|
||||
cfg PresetConfig
|
||||
nodes []inventory.Node
|
||||
want map[string]string // preset -> part of why it is not added
|
||||
}{
|
||||
{"auto off", testModels(), PresetConfig{Auto: false, ComfyUI: "0.39.2"}, []inventory.Node{v100Node("g")},
|
||||
map[string]string{"flux-schnell": "presets.auto is off", "triposr": "presets.auto is off"}},
|
||||
{"no ComfyUI", testModels(), PresetConfig{Auto: true}, []inventory.Node{v100Node("g")},
|
||||
map[string]string{"triposr": "engines.comfyui"}},
|
||||
{"no card", testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, nil,
|
||||
map[string]string{"triposr": "no node has a card"}},
|
||||
{"a picture model already", append(testModels(), picture), PresetConfig{Auto: true, ComfyUI: "0.39.2"},
|
||||
[]inventory.Node{v100Node("g")}, map[string]string{"flux-schnell": "sdxl"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
a, fm, _ := presetsAPI(t, c.models, c.cfg, c.nodes...)
|
||||
v, err := a.Presets(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.ComfyUI != c.cfg.ComfyUI {
|
||||
t.Errorf("comfyui %q", v.ComfyUI)
|
||||
}
|
||||
for _, p := range v.Presets {
|
||||
if why, ok := c.want[p.Name]; ok && !strings.Contains(p.Blocked, why) {
|
||||
t.Errorf("%s blocked %q, want it to say %q", p.Name, p.Blocked, why)
|
||||
}
|
||||
}
|
||||
_, _ = a.PresetsPass(t.Context())
|
||||
for _, m := range fm.added {
|
||||
if _, blocked := c.want[m.Name]; blocked {
|
||||
t.Errorf("%s was added", m.Name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPresetRemovedStaysOutUntilAddedAgain(t *testing.T) {
|
||||
a, fm, _ := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, v100Node("g"))
|
||||
if code := call(t, a, http.MethodDelete, "/api/v1/models/triposr", "", nil); code != http.StatusOK {
|
||||
t.Fatalf("remove %d", code)
|
||||
}
|
||||
var v PresetsView
|
||||
call(t, a, http.MethodGet, "/api/v1/presets", "", &v)
|
||||
i := slices.IndexFunc(v.Presets, func(p PresetView) bool { return p.Name == "triposr" })
|
||||
if i < 0 || !v.Presets[i].Declined || !strings.Contains(v.Presets[i].Blocked, "removed") {
|
||||
t.Fatalf("after removal %+v", v.Presets)
|
||||
}
|
||||
added, _ := a.PresetsPass(t.Context())
|
||||
if slices.Contains(added, "triposr") {
|
||||
t.Error("a removed preset was added back by itself")
|
||||
}
|
||||
if code := call(t, a, http.MethodPost, "/api/v1/presets/triposr", "", nil); code != http.StatusOK {
|
||||
t.Fatalf("add again %d", code)
|
||||
}
|
||||
if fm.added[len(fm.added)-1].Name != "triposr" {
|
||||
t.Error("adding it again did not add it")
|
||||
}
|
||||
call(t, a, http.MethodGet, "/api/v1/presets", "", &v)
|
||||
i = slices.IndexFunc(v.Presets, func(p PresetView) bool { return p.Name == "triposr" })
|
||||
if v.Presets[i].Declined {
|
||||
t.Error("adding it again left it declined")
|
||||
}
|
||||
if code := call(t, a, http.MethodPost, "/api/v1/presets/nope", "", nil); code != http.StatusNotFound {
|
||||
t.Errorf("unknown preset %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetsAutoIsTheClusterSetting(t *testing.T) {
|
||||
a, _, st := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"})
|
||||
if code := call(t, a, http.MethodPut, "/api/v1/presets/auto", `{"on":false}`, nil); code != http.StatusOK {
|
||||
t.Fatalf("put %d", code)
|
||||
}
|
||||
cur, _ := st.ClusterSettings(t.Context())
|
||||
if !strings.Contains(cur.Body, "auto: false") {
|
||||
t.Errorf("settings %q", cur.Body)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type Param struct {
|
||||
// before them and their name.
|
||||
var PathParams = map[string]string{
|
||||
"models/{name}": "The model's name in the catalog.",
|
||||
"presets/{name}": "The preset's name, as `llmgw ctl model presets` lists it.",
|
||||
"nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.",
|
||||
"jobs/{id}": "The job's ID, as starting it answered.",
|
||||
"runs/{id}": "The quality run's ID, as starting it answered.",
|
||||
@@ -136,6 +137,12 @@ var Routes = []Route{
|
||||
Request: CollectRequest{}, OptionalBody: true, Response: []CollectResult{}},
|
||||
|
||||
// The catalog.
|
||||
{Method: "GET", Path: "/api/v1/presets", Summary: "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined",
|
||||
Response: PresetsView{}},
|
||||
{Method: "POST", Path: "/api/v1/presets/{name}", Summary: "Add a preset to the catalog, and clear its decline",
|
||||
Response: ModelAdded{}},
|
||||
{Method: "PUT", Path: "/api/v1/presets/auto", Summary: "Turn on or off the adding of presets when a node fits them",
|
||||
Request: PresetsAutoRequest{}, Response: PresetsView{}},
|
||||
{Method: "GET", Path: "/api/v1/models", Summary: "Every model, with its replicas and state",
|
||||
Response: []ModelView{}},
|
||||
{Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog",
|
||||
|
||||
@@ -313,6 +313,9 @@ var Reference = []Block{
|
||||
{Key: "balance_abs", Type: "float64", Doc: "", Nested: ""},
|
||||
{Key: "balance_rel", Type: "float64", Doc: "", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "PresetSettings", Title: "PresetSettings", Doc: "", Fields: []Field{
|
||||
{Key: "auto", Type: "bool", Doc: "Auto has the leading control plane add a picture model (flux-schnell)\nand a 3D model (triposr) by itself, on demand, once a node has a card\nthat fits one and the catalog has no model that makes the same kind of\nthing — so a new cluster's portal has its pictures and 3D pages\nwithout anybody writing a definition. Only where the cluster already\ninstalls ComfyUI (engines.comfyui). A preset somebody removed is not\nadded again by itself. In force on the leader within a minute.", Nested: ""},
|
||||
}},
|
||||
{Package: "catalog", Name: "PreviousOrigin", Title: "PreviousOrigin", Doc: "", Fields: []Field{
|
||||
{Key: "source", Type: "string", Doc: "", Nested: ""},
|
||||
{Key: "revision", Type: "string", Doc: "", Nested: ""},
|
||||
@@ -438,6 +441,7 @@ var Reference = []Block{
|
||||
{Key: "mail", Type: "mail.Config", Doc: "Mail is where the gateway's few emails go out from: a link to set a\nforgotten password. Unset, none is sent and people ask an operator.", Nested: "mail.Config"},
|
||||
{Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"},
|
||||
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
|
||||
{Key: "presets", Type: "PresetSettings", Doc: "Presets is how the models that come with llmgw reach the catalog.", Nested: "config.PresetSettings"},
|
||||
{Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.<id>). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"},
|
||||
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
|
||||
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
|
||||
|
||||
@@ -35,6 +35,7 @@ var Sources = []string{
|
||||
"internal/config/oidc.go",
|
||||
"internal/config/firewall.go",
|
||||
"internal/config/serving.go",
|
||||
"internal/config/presets.go",
|
||||
"internal/config/agents.go",
|
||||
"internal/mail/mail.go",
|
||||
"internal/notify/notify.go",
|
||||
|
||||
@@ -27,6 +27,7 @@ var copyOrder = []string{
|
||||
"memberships",
|
||||
// After users, which it points at.
|
||||
"user_excluded_licences",
|
||||
"preset_state",
|
||||
"api_keys",
|
||||
"key_claims",
|
||||
"sessions",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Ported from the SQLite migration of the same number.
|
||||
-- The models that come with llmgw, as they stand in this cluster: who added
|
||||
-- one and when ("auto" for the leader), and whether somebody removed it
|
||||
-- since, so that the leader does not add it back by itself.
|
||||
CREATE TABLE preset_state (
|
||||
name TEXT PRIMARY KEY,
|
||||
added_by TEXT NOT NULL DEFAULT '',
|
||||
added_at BIGINT NOT NULL DEFAULT 0,
|
||||
declined BIGINT NOT NULL DEFAULT 0,
|
||||
declined_by TEXT NOT NULL DEFAULT '',
|
||||
declined_at BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- The models that come with llmgw, as they stand in this cluster: who added
|
||||
-- one and when ("auto" for the leader), and whether somebody removed it
|
||||
-- since, so that the leader does not add it back by itself.
|
||||
CREATE TABLE preset_state (
|
||||
name TEXT PRIMARY KEY,
|
||||
added_by TEXT NOT NULL DEFAULT '',
|
||||
added_at INTEGER NOT NULL DEFAULT 0,
|
||||
declined INTEGER NOT NULL DEFAULT 0,
|
||||
declined_by TEXT NOT NULL DEFAULT '',
|
||||
declined_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PresetState is one preset as it stands in this cluster.
|
||||
type PresetState struct {
|
||||
Name string
|
||||
// AddedBy is "auto" when the leader added it, who did otherwise.
|
||||
AddedBy string
|
||||
AddedAt time.Time
|
||||
// Declined is somebody removing it after it was added: the leader does
|
||||
// not add it back by itself.
|
||||
Declined bool
|
||||
DeclinedBy string
|
||||
DeclinedAt time.Time
|
||||
}
|
||||
|
||||
// PresetAdder is the AddedBy of a preset the leader added by itself.
|
||||
const PresetAdder = "auto"
|
||||
|
||||
// PresetStates is every preset that was ever added or declined, by name.
|
||||
func (s *DB) PresetStates(ctx context.Context) (map[string]PresetState, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT name, added_by, added_at, declined, declined_by, declined_at FROM preset_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[string]PresetState{}
|
||||
for rows.Next() {
|
||||
var p PresetState
|
||||
var added, declinedAt int64
|
||||
var declined int
|
||||
if err := rows.Scan(&p.Name, &p.AddedBy, &added, &declined, &p.DeclinedBy, &declinedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.AddedAt, p.Declined, p.DeclinedAt = presetTime(added), declined != 0, presetTime(declinedAt)
|
||||
out[p.Name] = p
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PresetAdded records a preset added, by whom, and clears its decline.
|
||||
func (s *DB) PresetAdded(ctx context.Context, name, by string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO preset_state (name, added_by, added_at, declined, declined_by, declined_at) VALUES (?, ?, ?, 0, '', 0)
|
||||
ON CONFLICT (name) DO UPDATE SET added_by = excluded.added_by, added_at = excluded.added_at,
|
||||
declined = 0, declined_by = '', declined_at = 0`,
|
||||
name, by, at.UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// PresetDeclined records a preset removed after it was added, so the leader
|
||||
// does not add it back.
|
||||
func (s *DB) PresetDeclined(ctx context.Context, name, by string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO preset_state (name, declined, declined_by, declined_at) VALUES (?, 1, ?, ?)
|
||||
ON CONFLICT (name) DO UPDATE SET declined = 1, declined_by = excluded.declined_by, declined_at = excluded.declined_at`,
|
||||
name, by, at.UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// presetTime is a stored instant, zero for none.
|
||||
func presetTime(ms int64) time.Time {
|
||||
if ms == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return fromMillis(ms)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPresetDeclinedStaysSoUntilAddedAgain(t *testing.T) {
|
||||
db := openDB(t)
|
||||
ctx := t.Context()
|
||||
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
if err := db.PresetAdded(ctx, "triposr", PresetAdder, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.PresetDeclined(ctx, "triposr", "ops@example.com", at.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Removed before it was ever added as a preset: declined all the same.
|
||||
if err := db.PresetDeclined(ctx, "flux-schnell", "ops@example.com", at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.PresetStates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := got["triposr"]; p.AddedBy != PresetAdder || !p.AddedAt.Equal(at) || !p.Declined || p.DeclinedBy != "ops@example.com" {
|
||||
t.Errorf("triposr %+v", p)
|
||||
}
|
||||
if p := got["flux-schnell"]; !p.Declined || p.AddedBy != "" || !p.AddedAt.IsZero() {
|
||||
t.Errorf("flux %+v", p)
|
||||
}
|
||||
if err := db.PresetAdded(ctx, "triposr", "ops@example.com", at.Add(2*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = db.PresetStates(ctx)
|
||||
if p := got["triposr"]; p.Declined || p.AddedBy != "ops@example.com" || !p.DeclinedAt.IsZero() {
|
||||
t.Errorf("added again, %+v", p)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user