diff --git a/internal/inventory/config_test.go b/internal/inventory/config_test.go new file mode 100644 index 00000000..749d4d05 --- /dev/null +++ b/internal/inventory/config_test.go @@ -0,0 +1,274 @@ +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) + } +} diff --git a/internal/inventory/forget_test.go b/internal/inventory/forget_test.go index b28e317c..5283b77f 100644 --- a/internal/inventory/forget_test.go +++ b/internal/inventory/forget_test.go @@ -39,7 +39,7 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) { if err := inv.RegisterModule(ctx, m, Source{}); err != nil { t.Fatal(err) } - if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil { + if err := inv.KeepSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil { t.Fatal(err) } if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"port": 9000}); err != nil { @@ -162,7 +162,7 @@ func TestDiscardingAModuleSaysWhatWentWithIt(t *testing.T) { if err := inv.RegisterModule(ctx, withOwnSecret(manifest("step-ca", nil, nil), "password"), Source{}); err != nil { t.Fatal(err) } - if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil { + if err := inv.KeepSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil { t.Fatal(err) } if err := inv.AcceptSecretForModule(ctx, "anchor", "step-ca", "password", "sealed"); err != nil { diff --git a/internal/inventory/settings_judged_test.go b/internal/inventory/settings_judged_test.go index 1563fa17..6fb84b5d 100644 --- a/internal/inventory/settings_judged_test.go +++ b/internal/inventory/settings_judged_test.go @@ -40,15 +40,17 @@ func TestASettingIsJudgedWhereItIsStored(t *testing.T) { } // A key that reaches nothing is refused too, where the spelling can still be fixed; a module // with a mergeable file takes any key. - err = inv.SetSettings(ctx, "", "plain", map[string]any{"colour": "blue"}) + err = inv.SetSettings(ctx, "anchor", "plain", map[string]any{"colour": "blue"}) if err == nil || !strings.Contains(err.Error(), "reach nothing") || !strings.Contains(err.Error(), `"colour"`) { t.Fatalf("a stray key was stored: %v", err) } - if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}); err != nil { - t.Fatal(err) + // There is no module layer for the whole mesh (novox/hq issue 630): a new one is refused. + err = inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}) + if err == nil || !strings.Contains(err.Error(), "no module layer for the whole mesh") { + t.Fatalf("a module layer for the whole mesh was stored: %v", err) } - // The mesh-wide layer is under the node's when the node's is judged. - if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil { + if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{"colour": "blue", + catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil { t.Fatal(err) } // A kept network is for an adopted machine only (rule 4). diff --git a/internal/inventory/unseen_test.go b/internal/inventory/unseen_test.go index 73a98031..3fcfe5d1 100644 --- a/internal/inventory/unseen_test.go +++ b/internal/inventory/unseen_test.go @@ -56,10 +56,10 @@ func TestTheLayerASetReplacesIsKeptAndReadable(t *testing.T) { t.Errorf("the replaced layer still has what the set dropped: %+v", past[1]) } // The mesh-wide layer keeps its own history, apart from the node's. - if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "green"}); err != nil { + if err := inv.KeepSettings(ctx, "", "web", map[string]any{"colour": "green"}); err != nil { t.Fatal(err) } - if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "grey"}); err != nil { + if err := inv.KeepSettings(ctx, "", "web", map[string]any{"colour": "grey"}); err != nil { t.Fatal(err) } mesh, err := inv.SettingsHistory(ctx, "", "web")