Keep mesh and node config with their history, and move whole-mesh module layers onto each assigned node (hq issue 630)
An assignment is one module on one node, so a new whole-mesh layer is refused and the ones held are laid under each assigned node's layer, every replaced layer kept, so no machine composes differently.
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// Mesh config and node config (novox/hq issue 630): a value for a predefined key, for the whole mesh or for
|
||||
// one node, that every module whose preference names the key falls back to. The list of predefined keys is
|
||||
// the catalogue's predefined.json, kept here as the last build of the catalogue carried it.
|
||||
|
||||
// ErrNotPredefined is a key mesh or node config was asked to hold that is not on the list.
|
||||
var ErrNotPredefined = errors.New("not a predefined key")
|
||||
|
||||
// SetPredefined replaces the list of predefined keys with the one a build of `repository` at `commit`
|
||||
// carried. Replaced whole: the file is a statement of the whole list.
|
||||
func (i *Inventory) SetPredefined(ctx context.Context, list catalogue.Predefined, repository, commit string) error {
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
if _, err := tx.Exec(ctx, `delete from predefined_key`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range list.Keys() {
|
||||
k := list[key]
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into predefined_key (key, type, why, repository, commit) values ($1, $2, $3, $4, $5)`,
|
||||
key, k.Type, k.Why, nullable(repository), nullable(commit)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Predefined is the list of predefined keys the mesh holds; empty before a build of the catalogue carried one.
|
||||
func (i *Inventory) Predefined(ctx context.Context) (catalogue.Predefined, error) {
|
||||
rows, err := i.store.Pool().Query(ctx, `select key, type, why from predefined_key`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := catalogue.Predefined{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var k catalogue.PredefinedKey
|
||||
if err := rows.Scan(&key, &k.Type, &k.Why); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = k
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ConfigValue is one value of mesh config (Node "") or node config.
|
||||
type ConfigValue struct {
|
||||
Key string
|
||||
Node string
|
||||
Value any
|
||||
SetAt time.Time
|
||||
SetBy string
|
||||
Why string
|
||||
}
|
||||
|
||||
// Config is every value of mesh config and node config, by key, the mesh's first, then by node.
|
||||
func (i *Inventory) Config(ctx context.Context) ([]ConfigValue, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select c.key, coalesce(n.name, ''), c.value, c.set_at, coalesce(c.set_by, ''), c.why
|
||||
from config c left join node n on n.id = c.node
|
||||
order by c.key, c.node is not null, n.name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ConfigValue
|
||||
for rows.Next() {
|
||||
var v ConfigValue
|
||||
var raw []byte
|
||||
if err := rows.Scan(&v.Key, &v.Node, &raw, &v.SetAt, &v.SetBy, &v.Why); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &v.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetConfig sets a predefined key's value for the whole mesh (node "") or for one node. Refused for a key not
|
||||
// on the list and for a value not of the key's type. The value it replaces is kept in config_history.
|
||||
func (i *Inventory) SetConfig(ctx context.Context, key, nodeName string, value any, setBy, why string) error {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return errors.New("config says why: one sentence, kept with the value")
|
||||
}
|
||||
if err := i.JudgeConfig(ctx, key, nodeName, value); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodeID, err := i.layerNode(ctx, nodeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
if _, err := tx.Exec(ctx, keepConfigSQL, key, nodeID, "set", why); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`delete from config where key = $1 and node is not distinct from $2::uuid`, key, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into config (key, node, value, set_by, why) values ($1, $2::uuid, $3, $4, $5)`,
|
||||
key, nodeID, raw, nullable(setBy), why); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// JudgeConfig refuses, before anything is done, a value mesh or node config may not hold: a key not on the
|
||||
// list of predefined keys, or a value not of the key's type.
|
||||
func (i *Inventory) JudgeConfig(ctx context.Context, key, nodeName string, value any) error {
|
||||
list, err := i.Predefined(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
k, on := list[key]
|
||||
if !on {
|
||||
return notPredefined(key, nodeName, list)
|
||||
}
|
||||
return catalogue.ConfigValueProblem(fmt.Sprintf("the predefined key %q", key), k.Type, value)
|
||||
}
|
||||
|
||||
// ClearConfig removes a predefined key's value for the whole mesh (node "") or for one node, keeping it in
|
||||
// config_history; false when there was none. A key no longer on the list may still be cleared.
|
||||
func (i *Inventory) ClearConfig(ctx context.Context, key, nodeName, clearedBy, why string) (bool, error) {
|
||||
if strings.TrimSpace(why) == "" {
|
||||
return false, errors.New("config says why: one sentence, kept with the history")
|
||||
}
|
||||
nodeID, err := i.layerNode(ctx, nodeName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }()
|
||||
if _, err := tx.Exec(ctx, keepConfigSQL, key, nodeID, "clear", why); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if clearedBy != "" {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update config_history set replaced_by = 'clear by ' || $3
|
||||
where key = $1 and node is not distinct from $2::uuid
|
||||
and replaced_at = (select max(replaced_at) from config_history
|
||||
where key = $1 and node is not distinct from $2::uuid)`,
|
||||
key, nodeID, clearedBy); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `delete from config where key = $1 and node is not distinct from $2::uuid`, key, nodeID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return tag.RowsAffected() > 0, tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// PastConfig is one value of mesh or node config that was replaced or cleared.
|
||||
type PastConfig struct {
|
||||
Value any
|
||||
SetAt time.Time
|
||||
SetBy string
|
||||
Why string
|
||||
ReplacedAt time.Time
|
||||
ReplacedBy string
|
||||
ReplacedWhy string
|
||||
}
|
||||
|
||||
// ConfigHistory is the values a predefined key had for the whole mesh (node "") or one node, latest first.
|
||||
func (i *Inventory) ConfigHistory(ctx context.Context, key, nodeName string) ([]PastConfig, error) {
|
||||
nodeID, err := i.layerNode(ctx, nodeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select value, coalesce(set_at, 'epoch'), coalesce(set_by, ''), coalesce(why, ''), replaced_at, replaced_by,
|
||||
coalesce(replaced_why, '')
|
||||
from config_history where key = $1 and node is not distinct from $2::uuid order by replaced_at desc`,
|
||||
key, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PastConfig
|
||||
for rows.Next() {
|
||||
var p PastConfig
|
||||
var raw []byte
|
||||
if err := rows.Scan(&raw, &p.SetAt, &p.SetBy, &p.Why, &p.ReplacedAt, &p.ReplacedBy, &p.ReplacedWhy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p.Value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// keepConfigSQL copies the value about to be replaced or cleared into the history, with why it goes.
|
||||
const keepConfigSQL = `insert into config_history (key, node, value, set_at, set_by, why, replaced_by, replaced_why)
|
||||
select key, node, value, set_at, set_by, why, $3, $4 from config
|
||||
where key = $1 and node is not distinct from $2::uuid`
|
||||
|
||||
func notPredefined(key, nodeName string, list catalogue.Predefined) error {
|
||||
where := "mesh config"
|
||||
if nodeName != "" {
|
||||
where = "node config"
|
||||
}
|
||||
listed := "the list holds none yet: it arrives with a build of the catalogue carrying " + catalogue.PredefinedFile
|
||||
if len(list) > 0 {
|
||||
listed = "the list holds " + strings.Join(list.Keys(), ", ")
|
||||
}
|
||||
return fmt.Errorf("%w: %s holds values only for the predefined keys, and %q is not one (%s); a module's own "+
|
||||
"value is set with module and node", ErrNotPredefined, where, key, listed)
|
||||
}
|
||||
|
||||
// configLayers are the layers mesh config and node config make for one module on one node: nothing when
|
||||
// neither holds a value, or the module names no predefined key.
|
||||
func (i *Inventory) configLayers(ctx context.Context, nodeID, nodeName, module string) ([]catalogue.Layer, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select key, node is null, value from config where node is null or node = $1`, nodeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
mesh, node := map[string]any{}, map[string]any{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var meshWide bool
|
||||
var raw []byte
|
||||
if err := rows.Scan(&key, &meshWide, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if meshWide {
|
||||
mesh[key] = v
|
||||
} else {
|
||||
node[key] = v
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(mesh)+len(node) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Only the module's preferences, read from its stored definition: a whole catalogue per module per
|
||||
// node would be read for every machine planned.
|
||||
var raw []byte
|
||||
err = i.store.Pool().QueryRow(ctx, `select manifest -> 'preferences' from module where name = $1`, module).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) || len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var prefs map[string]catalogue.Preference
|
||||
if err := json.Unmarshal(raw, &prefs); err != nil {
|
||||
return nil, fmt.Errorf("%s's preferences cannot be read: %w", module, err)
|
||||
}
|
||||
return catalogue.ConfigLayers(catalogue.Manifest{Module: module, Preferences: prefs}, mesh, node, nodeName), nil
|
||||
}
|
||||
|
||||
// SetModuleValue sets or clears one key of a module's own layer on one node — its module-assignment config —
|
||||
// keeping the rest of the layer, judged as `settings set` judges it, with the layer it replaces kept.
|
||||
// value nil clears the key; the layer goes when no key is left.
|
||||
func (i *Inventory) SetModuleValue(ctx context.Context, nodeName, module, key string, value any, setBy string) (
|
||||
before any, had bool, err error) {
|
||||
if nodeName == "" {
|
||||
return nil, false, errors.New("there is no module layer for the whole mesh: a module's own value is " +
|
||||
"set on one node (novox/hq issue 630); for every node, set mesh config for a predefined key")
|
||||
}
|
||||
layer, has, err := i.Layer(ctx, nodeName, module)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
values := map[string]any{}
|
||||
for k, v := range layer {
|
||||
values[k] = v
|
||||
}
|
||||
before, had = values[key]
|
||||
if value == nil {
|
||||
if !had {
|
||||
return nil, false, nil
|
||||
}
|
||||
delete(values, key)
|
||||
if len(values) == 0 && has {
|
||||
return before, had, i.ClearSettingsBy(ctx, nodeName, module, setBy)
|
||||
}
|
||||
} else {
|
||||
values[key] = value
|
||||
}
|
||||
return before, had, i.SetSettingsBy(ctx, nodeName, module, values, setBy)
|
||||
}
|
||||
|
||||
// ConfigKeysSorted is the keys of a set of config values, sorted, for saying them.
|
||||
func ConfigKeysSorted(values []ConfigValue) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, v := range values {
|
||||
if !seen[v.Key] {
|
||||
seen[v.Key] = true
|
||||
out = append(out, v.Key)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user