package inventory import ( "encoding/json" "fmt" "strings" "testing" "github.com/novox/mesh-controller/internal/catalogue" ) // novox/hq ADR 0328 §3, §4, §15: one number per joining module (its user's and its own group's) and one per library's // group, from one sequence and one range — never 0 or a root group's, never shared between a module and a library, // never given twice, the same however often asked. func TestOneNumberPerModuleAndLibraryNeverRoot(t *testing.T) { inv := fresh(t) ctx := t.Context() radarr, err := inv.AllocateIdentity(ctx, "radarr") if err != nil { t.Fatal(err) } again, err := inv.AllocateIdentity(ctx, "radarr") if err != nil || again != radarr { t.Fatalf("radarr was given %+v, then %+v (%v): one number per module", radarr, again, err) } media, err := inv.AllocateLibraryGroup(ctx, "media") if err != nil { t.Fatal(err) } if mediaAgain, err := inv.AllocateLibraryGroup(ctx, "media"); err != nil || mediaAgain != media { t.Fatalf("the media group was given %d, then %d (%v)", media, mediaAgain, err) } plex, err := inv.AllocateIdentity(ctx, "plex") if err != nil { t.Fatal(err) } seen := map[int]string{} for who, n := range map[string]int{"radarr": radarr.UID, "plex": plex.UID, "mesh-media": media} { if n == 0 || !catalogue.AllocatableID(n) { t.Errorf("%s was given %d: within %d–%d, never 0", who, n, catalogue.IdentityLow, catalogue.IdentityHigh) } if other, twice := seen[n]; twice { t.Errorf("%s and %s share %d: a module's own group and a library's never do", who, other, n) } seen[n] = who } // Read back as every machine's composition reads them: the same numbers. ids, err := inv.Identities(ctx) if err != nil { t.Fatal(err) } groups, err := inv.LibraryGroups(ctx) if err != nil { t.Fatal(err) } if ids["radarr"].UID != radarr.UID || groups["media"] != media { t.Fatalf("read back %+v and %v", ids, groups) } // A forgotten row frees no number: the next one is new. if _, err := inv.store.Pool().Exec(ctx, `delete from module_identity where module = 'plex'`); err != nil { t.Fatal(err) } sonarr, err := inv.AllocateIdentity(ctx, "sonarr") if err != nil { t.Fatal(err) } if _, given := seen[sonarr.UID]; given { t.Fatalf("sonarr was given %d, a number given before", sonarr.UID) } // The store refuses a number outside the range, root's above all. for _, n := range []int{0, 4, 10, 1000, 49999, 60000} { if _, err := inv.store.Pool().Exec(ctx, `insert into module_identity (module, uid) values ('byhand', $1)`, n); err == nil { t.Errorf("the store took %d as a module's number", n) } if _, err := inv.store.Pool().Exec(ctx, `insert into library_group (library, gid) values ('byhand', $1)`, n); err == nil { t.Errorf("the store took %d as a library group's number", n) } } } // libraryController registers the controller's own module, whose mesh-wide layer defines the libraries. func libraryController(t *testing.T, inv *Inventory) { t.Helper() m := catalogue.Manifest{Module: catalogue.LibrariesModule, Version: "1", Resources: []map[string]any{{"id": "server", "type": "container", "name": "controller", "image": "x"}}} if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil { t.Fatal(err) } } // novox/hq ADR 0328 §1, §4, §13: a library is defined only on the controller's mesh-wide layer, as a trusted setting; // never on one machine; never overlapping another on a machine it reaches; never named as a registered module — and no // module is registered under a defined library's name. func TestALibraryIsDefinedOnceForTheMeshAndNeverOverlaps(t *testing.T) { inv := fresh(t) ctx := t.Context() for _, n := range []string{"ace", "novox"} { if _, err := inv.AddNode(ctx, n); err != nil { t.Fatal(err) } } libraryController(t, inv) radarr := catalogue.Manifest{Module: "radarr", Version: "1", Resources: []map[string]any{{"id": "server", "type": "container", "name": "radarr", "image": "x"}}} if err := inv.RegisterModule(ctx, radarr, Source{}); err != nil { t.Fatal(err) } media := map[string]any{"home": "ace", "root": "/storage/media", "reach": []any{"novox"}} define := func(libs map[string]any) map[string]any { return map[string]any{catalogue.LibrariesSetting: libs} } if err := inv.SetSettings(ctx, "", catalogue.LibrariesModule, define(map[string]any{"media": media})); err != nil { t.Fatalf("a library was not defined: %v", err) } libs, err := inv.Libraries(ctx) if err != nil { t.Fatal(err) } if l := libs["media"]; l.Home != "ace" || l.Root != "/storage/media" || !l.Reaches("novox") || !l.Reaches("ace") { t.Fatalf("the library reads %+v", libs["media"]) } for name, c := range map[string]struct { node string libs map[string]any want string }{ "on one machine": {"ace", map[string]any{"media": media}, "never on one machine"}, "inside another": {"", map[string]any{"media": media, "movies": map[string]any{"home": "novox", "root": "/storage/media/movies"}}, "overlap on"}, "around another": {"", map[string]any{"movies": map[string]any{"home": "ace", "root": "/storage/media/movies"}, "media": media}, "overlap on"}, "named as a module": {"", map[string]any{"radarr": map[string]any{"home": "ace", "root": "/srv/radarr-data"}}, "name of the module radarr"}, "with an owner": {"", map[string]any{"books": map[string]any{"home": "ace", "root": "/srv/books", "owner": "1000"}}, "nothing about an owner"}, } { err := inv.SetSettings(ctx, c.node, catalogue.LibrariesModule, define(c.libs)) if err == nil || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: %v; want refused saying %q", name, err, c.want) } } // Two libraries apart on different machines may share a path's prefix only where they do not meet. apart := map[string]any{"media": media, "photos": map[string]any{"home": "elsewhere", "root": "/storage/media/photos"}} if err := inv.JudgeSettings(ctx, "", catalogue.LibrariesModule, define(apart)); err != nil { t.Errorf("libraries that meet on no machine were refused: %v", err) } // A module under a defined library's name is refused. named := catalogue.Manifest{Module: "media", Version: "1", Resources: []map[string]any{{"id": "s", "type": "container", "name": "media", "image": "x"}}} if err := inv.RegisterModule(ctx, named, Source{}); err == nil || !strings.Contains(err.Error(), "name of the library media") { t.Fatalf("a module named as a library: %v", err) } } // novox/hq ADR 0328 §7: a library taken is recorded once per home and filesystem, under a warrant; one taken again on // another filesystem replaces it. func TestALibraryIsTakenOnceUnderAWarrant(t *testing.T) { inv := fresh(t) ctx := t.Context() if err := inv.RecordTaken(ctx, TakenLibrary{Library: "media", Home: "ace", By: "the operator"}); err == nil { t.Fatal("a library was recorded taken with no warrant") } first := TakenLibrary{Library: "media", Home: "ace", Placed: "fs-000000000001", By: "the operator", Ask: "c01", Warrant: "c01"} if err := inv.RecordTaken(ctx, first); err != nil { t.Fatal(err) } again := first again.Ask, again.Warrant, again.By = "c02", "c02", "someone else" if err := inv.RecordTaken(ctx, again); err != nil { t.Fatal(err) } taken, err := inv.TakenLibraries(ctx) if err != nil { t.Fatal(err) } if got := taken["media"]; got.Ask != "c01" { t.Fatalf("a second take of the same overwrote the first: %+v", got) } moved := first moved.Placed, moved.Ask, moved.Warrant = "fs-0000000000ff", "c03", "c03" if err := inv.RecordTaken(ctx, moved); err != nil { t.Fatal(err) } if taken, err = inv.TakenLibraries(ctx); err != nil || taken["media"].Ask != "c03" { t.Fatalf("a take on another filesystem: %+v (%v)", taken["media"], err) } } // novox/hq issue 575: a module registered without the access movies, whose newest build on its branch declares it — // the build the delivery under way brings — takes a proposal placing movies: judged against that build, not refused. // Without such a build it is refused, as before. func TestAPlacementIsJudgedAgainstTheBuildThatBringsTheAccess(t *testing.T) { inv := fresh(t) ctx := t.Context() if _, err := inv.AddNode(ctx, "ace"); err != nil { t.Fatal(err) } registered := catalogue.Manifest{Module: "media-importer", Version: "1", Accesses: []catalogue.Access{{ID: "downloads", Path: "/services/media/downloads", Mode: catalogue.AccessReadWrite}}, Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x", "volumes": []any{"${access:downloads}:/downloads"}}}} if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", Ref: "main", BuiltFrom: "c0ffee33"}); err != nil { t.Fatal(err) } placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}} err := inv.JudgeSettings(ctx, "ace", "media-importer", placing) if err == nil || !strings.Contains(err.Error(), `places the access "movies", which it does not declare`) { t.Fatalf("a placement no build declares: %v", err) } // main's manifest — the build of #34 — declares movies. ahead := registered ahead.Accesses = append([]catalogue.Access{}, registered.Accesses...) ahead.Accesses = append(ahead.Accesses, catalogue.Access{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}) raw, err := json.Marshal(ahead) if err != nil { t.Fatal(err) } b := aBuild("b34", "media-importer", "") b.Ref, b.Manifest = "main", raw if err := inv.RecordBuild(ctx, b); err != nil { t.Fatal(err) } if err := inv.JudgeSettings(ctx, "ace", "media-importer", placing); err != nil { t.Fatalf("the proposal placing movies, which main's build declares, was refused: %v", err) } if err := inv.SetSettings(ctx, "ace", "media-importer", placing); err != nil { t.Fatalf("the layer placing movies was not stored: %v", err) } // Stored ahead, it does not cost the registered module its place: composition passes it by. layers, err := inv.SettingsFor(ctx, "ace", "media-importer") if err != nil { t.Fatal(err) } if err := catalogue.ComposesWith(registered, layers, false); err != nil { t.Fatalf("the registered module no longer composes with the layer stored ahead: %v", err) } // A typo is still a typo: an access neither build declares is refused. typo := map[string]any{catalogue.AccessesSetting: map[string]any{"moveis": "/storage/media/movies"}} if err := inv.JudgeSettings(ctx, "ace", "media-importer", typo); err == nil { t.Fatal("a placement no build declares was taken") } } // Review of e1c2b744: only a build of the branch the module's record follows judges a placement ahead of it — never one // from another branch, nor one with no branch, and a module registered with no branch is refused as before, saying so. func TestOnlyABuildOfTheFollowedBranchJudgesAPlacementAhead(t *testing.T) { inv := fresh(t) ctx := t.Context() if _, err := inv.AddNode(ctx, "ace"); err != nil { t.Fatal(err) } registered := catalogue.Manifest{Module: "importer", Version: "1", Resources: []map[string]any{{"id": "server", "type": "container", "name": "importer", "image": "x"}}} ahead := registered ahead.Accesses = []catalogue.Access{{ID: "movies", Path: "/services/media/movies", Mode: catalogue.AccessReadWrite}} raw, err := json.Marshal(ahead) if err != nil { t.Fatal(err) } placing := map[string]any{catalogue.AccessesSetting: map[string]any{"movies": "/storage/media/movies"}} if err := inv.RegisterModule(ctx, registered, Source{Repository: "https://forge.invalid/media.git", Ref: "main", BuiltFrom: "c0ffee"}); err != nil { t.Fatal(err) } for i, ref := range []string{"feature", ""} { b := aBuild(fmt.Sprintf("elsewhere%d", i), "importer", "") b.Ref, b.Manifest = ref, raw if err := inv.RecordBuild(ctx, b); err != nil { t.Fatal(err) } } if err := inv.JudgeSettings(ctx, "ace", "importer", placing); err == nil { t.Fatal("a build of another branch, or of none, judged the placement") } // Registered by hand, with no branch: refused, saying why. byHand := registered byHand.Module = "byhand" if err := inv.RegisterModule(ctx, byHand, Source{}); err != nil { t.Fatal(err) } aheadByHand := ahead aheadByHand.Module = "byhand" if raw, err = json.Marshal(aheadByHand); err != nil { t.Fatal(err) } b := aBuild("onmain", "byhand", "") b.Ref, b.Manifest = "main", raw if err := inv.RecordBuild(ctx, b); err != nil { t.Fatal(err) } if err := inv.JudgeSettings(ctx, "ace", "byhand", placing); err == nil || !strings.Contains(err.Error(), "follows no branch") { t.Fatalf("a module with no branch recorded: %v", err) } }