275 lines
10 KiB
Go
275 lines
10 KiB
Go
package inventory
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-controller/internal/catalogue"
|
|
)
|
|
|
|
// dunstLike is a module whose font-size falls back to the predefined key font-size (novox/hq issue 630).
|
|
func dunstLike() catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "dunst", Version: "1",
|
|
Preferences: map[string]catalogue.Preference{
|
|
"font-size": {Type: catalogue.TypeNumber, Default: float64(10), Predefined: "font-size", Why: "readable at 100 DPI"},
|
|
"width": {Type: catalogue.TypeNumber, Default: float64(250), Why: "forty characters"},
|
|
},
|
|
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/dunstrc", "mode": "0644",
|
|
"content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}}}
|
|
}
|
|
|
|
func fontList() catalogue.Predefined {
|
|
return catalogue.Predefined{"font-size": {Type: catalogue.TypeNumber, Why: "the size text is drawn at"}}
|
|
}
|
|
|
|
// effectiveOn is a module's value for a key on a node, and its source, as composition reads them.
|
|
func effectiveOn(t *testing.T, inv *Inventory, node, key string) (any, string) {
|
|
t.Helper()
|
|
layers, err := inv.SettingsFor(t.Context(), node, "dunst")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, s := range catalogue.Effective(dunstLike(), layers) {
|
|
if s.Key == key {
|
|
if s.FromDefault {
|
|
return s.Value, catalogue.DefaultLayer
|
|
}
|
|
return s.Value, s.From
|
|
}
|
|
}
|
|
t.Fatalf("no %s", key)
|
|
return nil, ""
|
|
}
|
|
|
|
// Each of the four sources in turn, the most specific winning, and clearing each falls back to the next
|
|
// one down (novox/hq issue 630).
|
|
func TestAConfigValueComesFromFourSourcesTheMostSpecificWinning(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
for _, n := range []string{"g14", "shanks"} {
|
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := inv.SetPredefined(ctx, fontList(), "novox/mesh-catalog", "c1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RegisterModule(ctx, dunstLike(), Source{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, n := range []string{"g14", "shanks"} {
|
|
if _, err := inv.Assign(ctx, n, "dunst"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
want := func(node string, value float64, from string) {
|
|
t.Helper()
|
|
v, f := effectiveOn(t, inv, node, "font-size")
|
|
if v != value || f != from {
|
|
t.Fatalf("font-size on %s: %v from %q, want %v from %q", node, v, f, value, from)
|
|
}
|
|
}
|
|
want("g14", 10, "default")
|
|
|
|
if err := inv.SetConfig(ctx, "font-size", "", float64(11), "the operator", "a larger default everywhere"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want("g14", 11, "mesh config")
|
|
want("shanks", 11, "mesh config")
|
|
|
|
if err := inv.SetConfig(ctx, "font-size", "g14", float64(13), "the operator", "a dense screen"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want("g14", 13, "node config (g14)")
|
|
want("shanks", 11, "mesh config")
|
|
|
|
if _, _, err := inv.SetModuleValue(ctx, "g14", "dunst", "font-size", float64(15), "the operator"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want("g14", 15, "g14")
|
|
want("shanks", 11, "mesh config")
|
|
|
|
// A preference without predefined reads only its default and the module's own value.
|
|
if v, f := effectiveOn(t, inv, "g14", "width"); v != float64(250) || f != "default" {
|
|
t.Fatalf("width: %v from %s", v, f)
|
|
}
|
|
|
|
// Clearing each falls back to the next source down.
|
|
if _, _, err := inv.SetModuleValue(ctx, "g14", "dunst", "font-size", nil, "the operator"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want("g14", 13, "node config (g14)")
|
|
if removed, err := inv.ClearConfig(ctx, "font-size", "g14", "the operator", "back to the mesh's"); err != nil || !removed {
|
|
t.Fatalf("clear node config: %v %v", removed, err)
|
|
}
|
|
want("g14", 11, "mesh config")
|
|
if removed, err := inv.ClearConfig(ctx, "font-size", "", "the operator", "back to the default"); err != nil || !removed {
|
|
t.Fatalf("clear mesh config: %v %v", removed, err)
|
|
}
|
|
want("g14", 10, "default")
|
|
if _, has, _ := inv.Layer(ctx, "g14", "dunst"); has {
|
|
t.Fatal("the module's layer was left empty rather than removed")
|
|
}
|
|
|
|
// What each replaced is kept, with why.
|
|
past, err := inv.ConfigHistory(ctx, "font-size", "g14")
|
|
if err != nil || len(past) != 1 || past[0].Value != float64(13) || past[0].Why != "a dense screen" ||
|
|
past[0].ReplacedBy != "clear by the operator" || past[0].ReplacedWhy != "back to the mesh's" {
|
|
t.Fatalf("node config history: %+v %v", past, err)
|
|
}
|
|
}
|
|
|
|
// Mesh and node config hold values for predefined keys only, of their type; a module value needs a node.
|
|
func TestConfigRefusesAKeyNotPredefinedAWrongTypeAndAModuleValueForTheWholeMesh(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
if _, err := inv.AddNode(ctx, "g14"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err := inv.SetConfig(ctx, "font-size", "", float64(12), "the operator", "bigger")
|
|
if !errors.Is(err, ErrNotPredefined) || !strings.Contains(err.Error(), "the list holds none yet") {
|
|
t.Fatalf("a key before any list: %v", err)
|
|
}
|
|
if err := inv.SetPredefined(ctx, fontList(), "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = inv.SetConfig(ctx, "colour", "g14", "blue", "the operator", "blue")
|
|
if !errors.Is(err, ErrNotPredefined) || !strings.Contains(err.Error(), "node config") ||
|
|
!strings.Contains(err.Error(), "font-size") {
|
|
t.Fatalf("a key not on the list: %v", err)
|
|
}
|
|
if err := inv.SetConfig(ctx, "font-size", "", "large", "the operator", "bigger"); err == nil ||
|
|
!strings.Contains(err.Error(), "is a number") {
|
|
t.Fatalf("a string for a number: %v", err)
|
|
}
|
|
if err := inv.SetConfig(ctx, "font-size", "", float64(12), "the operator", ""); err == nil {
|
|
t.Fatal("a value without why was kept")
|
|
}
|
|
if err := inv.SetConfig(ctx, "font-size", "nowhere", float64(12), "the operator", "x"); err == nil {
|
|
t.Fatal("node config for a node the mesh does not know was kept")
|
|
}
|
|
if _, _, err := inv.SetModuleValue(ctx, "", "dunst", "font-size", float64(12), "the operator"); err == nil ||
|
|
!strings.Contains(err.Error(), "no module layer for the whole mesh") {
|
|
t.Fatalf("a module value for the whole mesh: %v", err)
|
|
}
|
|
if values, err := inv.Config(ctx); err != nil || len(values) != 0 {
|
|
t.Fatalf("a refused value was kept: %v %v", values, err)
|
|
}
|
|
}
|
|
|
|
// Registration refuses a preference whose predefined key is not on the list, or whose type differs.
|
|
func TestRegistrationJudgesPreferencesAgainstThePredefinedList(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
err := inv.RegisterModule(ctx, dunstLike(), Source{})
|
|
if err == nil || !strings.Contains(err.Error(), `predefined key "font-size"`) {
|
|
t.Fatalf("a predefined key before any list: %v", err)
|
|
}
|
|
if err := inv.SetPredefined(ctx, catalogue.Predefined{"font-size": {Type: catalogue.TypeString, Why: "x"}}, "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = inv.RegisterModule(ctx, dunstLike(), Source{})
|
|
if err == nil || !strings.Contains(err.Error(), "is a number") || !strings.Contains(err.Error(), "is a string") {
|
|
t.Fatalf("a type other than the list's: %v", err)
|
|
}
|
|
if err := inv.SetPredefined(ctx, fontList(), "", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.RegisterModule(ctx, dunstLike(), Source{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// The module layer for the whole mesh is moved into each assigned node's layer by migration 0096: the mesh's
|
|
// values under the node's, maps merged key by key, every replaced layer kept in the history (novox/hq issue 630).
|
|
func TestTheWholeMeshModuleLayerMovesIntoEachAssignedNode(t *testing.T) {
|
|
inv := fresh(t)
|
|
ctx := t.Context()
|
|
for _, n := range []string{"novox", "g14", "shanks"} {
|
|
if _, err := inv.AddNode(ctx, n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
m := catalogue.Manifest{Module: "artifact-store-tools", Version: "1",
|
|
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/t.json", "content": "{}", "merge": "json"}}}
|
|
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, n := range []string{"novox", "g14"} {
|
|
if _, err := inv.Assign(ctx, n, "artifact-store-tools"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// As held before the migration: a whole-mesh layer, and g14's own layer over it.
|
|
if err := inv.KeepSettings(ctx, "", "artifact-store-tools", map[string]any{
|
|
"older-store-names": "registry-api.novox.be", "nested": map[string]any{"a": 1, "b": 1}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := inv.SetSettings(ctx, "g14", "artifact-store-tools", map[string]any{
|
|
"nested": map[string]any{"b": 2}, "own": true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
migrations, err := Migrations()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var moving string
|
|
for _, mig := range migrations {
|
|
if mig.Number == 96 {
|
|
moving = mig.SQL[strings.Index(mig.SQL, "create function pg_temp"):]
|
|
}
|
|
}
|
|
if moving == "" {
|
|
t.Fatal("migration 0096 moves no whole-mesh layer")
|
|
}
|
|
tx, err := inv.store.Pool().Begin(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := tx.Exec(ctx, moving); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
layer := func(node string) string {
|
|
t.Helper()
|
|
values, _, err := inv.Layer(ctx, node, "artifact-store-tools")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(values)
|
|
return string(raw)
|
|
}
|
|
if got := layer("novox"); got != `{"nested":{"a":1,"b":1},"older-store-names":"registry-api.novox.be"}` {
|
|
t.Fatalf("novox's layer: %s", got)
|
|
}
|
|
if got := layer("g14"); got != `{"nested":{"a":1,"b":2},"older-store-names":"registry-api.novox.be","own":true}` {
|
|
t.Fatalf("g14's layer, the mesh's under its own: %s", got)
|
|
}
|
|
if got := layer("shanks"); got != "null" && got != "{}" {
|
|
t.Fatalf("a node the module is not assigned to gained a layer: %s", got)
|
|
}
|
|
if _, has, _ := inv.Layer(ctx, "", "artifact-store-tools"); has {
|
|
t.Fatal("the whole-mesh layer is still there")
|
|
}
|
|
mesh, err := inv.SettingsHistory(ctx, "", "artifact-store-tools")
|
|
if err != nil || len(mesh) != 1 || mesh[0].ReplacedBy != "moved-to-nodes" ||
|
|
mesh[0].Values["older-store-names"] != "registry-api.novox.be" {
|
|
t.Fatalf("the whole-mesh layer's history: %+v %v", mesh, err)
|
|
}
|
|
past, err := inv.SettingsHistory(ctx, "g14", "artifact-store-tools")
|
|
if err != nil || len(past) != 1 || past[0].ReplacedBy != "moved-to-nodes" || past[0].Values["own"] != true {
|
|
t.Fatalf("g14's replaced layer: %+v %v", past, err)
|
|
}
|
|
// And composition reads the same values on each node as before.
|
|
layers, err := inv.SettingsFor(ctx, "g14", "artifact-store-tools")
|
|
if err != nil || len(layers) != 1 {
|
|
t.Fatalf("g14's layers: %+v %v", layers, err)
|
|
}
|
|
}
|