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) } }