diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go index 9446f278..47b8b781 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go @@ -194,7 +194,7 @@ func heldCounts(s stdio.KeptState) func() (index.OldSectionsCount, error) { } func main() { - if err := stdio.Serve("", append(Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), append(append(catalogTools(), viewTools(os.Getenv, keptSource, time.Now)...), configReadersTool(stdio.NewWatchedStates()), busStateTool(keptSource), requirementsTool(keptSource))...)); err != nil { + if err := stdio.Serve("", append(Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), append(append(catalogTools(), viewTools(os.Getenv, keptSource, time.Now)...), configReadersTool(stdio.NewWatchedStates()), busStateTool(keptSource), requirementsTool(keptSource), packageUsersTool(keptSource))...)); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err) os.Exit(1) } diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go index 88612120..f5f06ad6 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/views.go @@ -125,3 +125,23 @@ func requirementsTool(state func(string) views.Source) stdio.Tool { return map[string]any{"package": pkg, "requirements": versions}, nil }} } + +// packageUsersTool is indexer_package_users (design 32 *A package, its series, and the series its users accept*; DLV-I8): +// per package, its users and the series each accepts, read from the indexer's own states. +func packageUsersTool(state func(string) views.Source) stdio.Tool { + return stdio.Tool{Name: index.PackageUsersTool, + Description: "Who uses each mesh package and which package series each accepts (design 32): per package its series with " + + "the newest published version of each, and its trunk users, each with the series it accepts (named in its manifest's " + + "packages, or the default), that series' newest published version and the version its fingerprint is pinned to, so a " + + "user left on an older series is seen. Give package (such as mesh-sdk/go) for one.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "package": map[string]any{"type": "string", "description": "one package by the builder's name, /; all when absent"}}}, + Run: func(args map[string]any) (any, error) { + only, _ := args["package"].(string) + got, err := index.PackageUsersOf(state("modules"), state("manifests"), state(delivery.PackagesState), only) + if err != nil { + return nil, err + } + return map[string]any{"packages": got}, nil + }} +} diff --git a/modules/mesh-delivery-indexer/internal/index/control_records_test.go b/modules/mesh-delivery-indexer/internal/index/control_records_test.go index 3102e381..1dd84d4c 100644 --- a/modules/mesh-delivery-indexer/internal/index/control_records_test.go +++ b/modules/mesh-delivery-indexer/internal/index/control_records_test.go @@ -3,6 +3,7 @@ package index import ( "context" "encoding/json" + "fmt" "reflect" "strings" "testing" @@ -138,6 +139,65 @@ func TestTheControlRecords(t *testing.T) { t.Errorf("the per-module digests read a series key: %v", got) } }) + // DLV-I8 (design 32 *A package, its series, and the series its users accept*): per package its series with the newest + // published of each, and its trunk users with the series each accepts: one naming 0.2 in packages, one reaching the Go + // module by go.sum on the default series, one by an import; a sibling of the package's own repository is no user, and a + // package only a manifest names is listed with no series. + t.Run("indexer_package_users", func(t *testing.T) { + sdkGo := "git.novox.be/novox/mesh-sdk/go" + modules, manifests, packages := memSource{}, memSource{}, memSource{} + putJSON := func(s memSource, key string, v any) { + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + s[key] = &views.Entry{Value: raw, Revision: 1} + } + putJSON(packages, SeriesKey("mesh-sdk/go", "0.1"), SeriesPackage{V: 1, Package: "mesh-sdk/go", Series: "0.1", GoModule: sdkGo, + Newest: &SeriesVersion{Version: "0.1.15"}}) + putJSON(packages, SeriesKey("mesh-sdk/go", "0.2"), SeriesPackage{V: 1, Package: "mesh-sdk/go", Series: "0.2", GoModule: sdkGo}) + for name, u := range map[string]struct { + repo, manifest string + pkgs, imports []string + pinned string + }{ + "new": {"novox/mesh-catalog", `{"module":"new","version":"1","packages":{"mesh-sdk/go":"0.2","web/ui":"1.0"}}`, nil, nil, ""}, + "old": {"novox/mesh-catalog", `{"module":"old","version":"1"}`, []string{sdkGo}, nil, "v0.1.15"}, + "imports": {"novox/mesh-tools", `{"module":"imports","version":"1"}`, nil, []string{sdkGo + "/views"}, ""}, + "sibling": {"novox/mesh-sdk", `{"module":"sibling","version":"1"}`, []string{sdkGo}, nil, ""}, + "none": {"novox/mesh-catalog", `{"module":"none","version":"1"}`, nil, nil, ""}, + } { + digest := fmt.Sprintf("%064x", len(name)*16+int(name[0])) + putJSON(manifests, digest, delivery.Manifest{V: delivery.Version, Module: name, JSON: u.manifest}) + mod := delivery.Module{FP2: "src2:" + strings.Repeat("a", 64), Manifest: digest, Repository: u.repo, Commit: "c", + Inputs2: &delivery.KeptInputs2{Packages: u.pkgs, Imports: u.imports}} + if u.pinned != "" { + mod.Packages = delivery.PackageVersions{sdkGo: u.pinned} + } + raw, err := delivery.EncodeModule(mod) + if err != nil { + t.Fatal(err) + } + modules[name] = &views.Entry{Value: raw, Revision: 1} + } + got, err := PackageUsersOf(modules, manifests, packages, "") + want := []PackageUses{ + {Package: "mesh-sdk/go", GoModule: sdkGo, Series: map[string]string{"0.1": "0.1.15", "0.2": ""}, Users: []PackageUser{ + {Module: "imports", Accepts: "0.1", Newest: "0.1.15"}, + {Module: "new", Accepts: "0.2", Named: true}, + {Module: "old", Accepts: "0.1", Newest: "0.1.15", Pinned: "v0.1.15"}}}, + {Package: "web/ui", Series: map[string]string{}, Users: []PackageUser{{Module: "new", Accepts: "1.0", Named: true}}}, + } + if err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("indexer_package_users = %+v (%v), want %+v", got, err, want) + } + if one, err := PackageUsersOf(modules, manifests, packages, "web/ui"); err != nil || len(one) != 1 || one[0].Package != "web/ui" { + t.Errorf("one package = %+v (%v)", one, err) + } + if _, err := PackageUsersOf(modules, manifests, packages, "nobody/x"); err == nil { + t.Error("a package nothing knows was answered") + } + }) ctx := context.Background() at := time.Date(2026, 10, 29, 22, 0, 0, 0, time.UTC) t.Run("the predefined list, never replaced by an older merge", func(t *testing.T) { diff --git a/modules/mesh-delivery-indexer/internal/index/package_users.go b/modules/mesh-delivery-indexer/internal/index/package_users.go new file mode 100644 index 00000000..0649e2ee --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/package_users.go @@ -0,0 +1,164 @@ +package index + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/views" +) + +// PackageUsersTool lists, per mesh package, its users and the series each accepts (DLV-I8; ADR 0606 ยง4; design 32 *A +// package, its series, and the series its users accept*), so a user left on an older series is seen. +const PackageUsersTool = "indexer_package_users" + +// PackageUser is one trunk module using a package: the series it accepts (Named when its manifest's `packages` names it, +// else the default), that series' newest published version ("" while none is), and the version its fingerprint pins. +type PackageUser struct { + Module string `json:"module"` + Accepts string `json:"accepts"` + Named bool `json:"named"` + Newest string `json:"newest"` + Pinned string `json:"pinned,omitempty"` +} + +// PackageUses is one package of indexer_package_users: its series held in the packages state, the newest each published +// (by series), its Go module path for a Go package, and its users in module order. +type PackageUses struct { + Package string `json:"package"` + GoModule string `json:"go-module,omitempty"` + Series map[string]string `json:"series"` + Users []PackageUser `json:"users"` +} + +// PackageUsersOf answers indexer_package_users from the indexer's own states (modules, manifests, packages), for one +// package or, with only empty, every package. A package is known by a series key of the packages state or by a name some +// trunk manifest's `packages` names. A trunk module uses it when its manifest names it, or, for a Go package, when its +// go.sum or its build's imports reach the package's Go module from another repository (a sibling by path is no user). +// A value that cannot be read is an error, never a user silently left out. +func PackageUsersOf(modules, manifests, packages views.Source, only string) ([]PackageUses, error) { + known := map[string]*PackageUses{} + get := func(name string) *PackageUses { + if known[name] == nil { + known[name] = &PackageUses{Package: name, Series: map[string]string{}, Users: []PackageUser{}} + } + return known[name] + } + keys, err := packages.Keys() + if err != nil { + return nil, fmt.Errorf("the packages state cannot be listed: %w", err) + } + for _, key := range keys { + if !IsSeriesKey(key) { + continue + } + e, ok, err := packages.Get(key) + if err != nil { + return nil, fmt.Errorf("packages.%s cannot be read: %w", key, err) + } + if !ok { + continue + } + var s SeriesPackage + if err := json.Unmarshal(e.Value, &s); err != nil { + return nil, fmt.Errorf("packages.%s holds what is not a series: %w", key, err) + } + p := get(s.Package) + if s.GoModule != "" { + p.GoModule = s.GoModule + } + p.Series[s.Series] = "" + if s.Newest != nil { + p.Series[s.Series] = s.Newest.Version + } + } + type trunk struct { + mod delivery.Module + manifest catalogue.Manifest + } + var mods []string + held := map[string]trunk{} + names, err := modules.Keys() + if err != nil { + return nil, fmt.Errorf("the modules state cannot be listed: %w", err) + } + for _, name := range names { + e, ok, err := modules.Get(name) + if err != nil { + return nil, fmt.Errorf("modules.%s cannot be read: %w", name, err) + } + if !ok { + continue + } + mod, err := delivery.DecodeModule(e.Value) + if err != nil || mod.Manifest == "" { + continue // no manifest held for it + } + me, ok, err := manifests.Get(mod.Manifest) + if err != nil { + return nil, fmt.Errorf("the manifest of %s cannot be read: %w", name, err) + } + if !ok { + continue + } + kept, err := delivery.DecodeManifest(me.Value) + if err != nil { + return nil, fmt.Errorf("the manifest of %s: %w", name, err) + } + m, err := catalogue.Decode([]byte(kept.JSON)) + if err != nil { + return nil, fmt.Errorf("the manifest of %s does not read: %w", name, err) + } + for pkg := range m.Packages { + get(pkg) + } + held[name], mods = trunk{mod, m}, append(mods, name) + } + slices.Sort(mods) + reaches := func(mod delivery.Module, goModule string) bool { + if goModule == "" || mod.Inputs2 == nil { + return false + } + if repo, _, ok := delivery.PackageRepository(goModule); !ok || delivery.SameRepository(repo, mod.Repository) { + return false + } + for _, p := range append(slices.Clone(mod.Inputs2.Packages), mod.Inputs2.Imports...) { + if p == goModule || strings.HasPrefix(p, goModule+"/") { + return true + } + } + return false + } + var out []PackageUses + for _, name := range slices.Sorted(maps.Keys(known)) { + if only != "" && name != only { + continue + } + p := known[name] + for _, m := range mods { + t := held[m] + _, named := t.manifest.Packages[name] + if !named && !reaches(t.mod, p.GoModule) { + continue + } + accepts := t.manifest.AcceptedSeries(name) + u := PackageUser{Module: m, Accepts: accepts, Named: named, Newest: p.Series[accepts]} + if p.GoModule != "" { + u.Pinned = t.mod.Packages[p.GoModule] + } + p.Users = append(p.Users, u) + } + out = append(out, *p) + } + if only != "" && len(out) == 0 { + return nil, fmt.Errorf("no series of %s is held and no trunk manifest names it", only) + } + if out == nil { + out = []PackageUses{} + } + return out, nil +} diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index 7e5f5ca9..cbc84e63 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -152,8 +152,8 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build t.Errorf("%s answers the state %s, which the views family does not say", v.Tool, v.State) } } - want = append(want, "indexer_first_views", "indexer_first_view_again", index.ConfigReadersTool, index.BusStateTool, index.RequirementsTool) // DLV-F3r, DLV-I10, DLV-I11, DLV-I7 - if !reflect.DeepEqual(m.Tools, want) || len(want) != 22 { + want = append(want, "indexer_first_views", "indexer_first_view_again", index.ConfigReadersTool, index.BusStateTool, index.RequirementsTool, index.PackageUsersTool) // DLV-F3r, DLV-I10, DLV-I11, DLV-I7, DLV-I8 + if !reflect.DeepEqual(m.Tools, want) || len(want) != 23 { t.Errorf("tools = %v, want %v", m.Tools, want) } if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs { diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index 8c449c4a..dd4b3958 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -37,7 +37,8 @@ "indexer_first_view_again", "indexer_config_readers", "indexer_bus_state", - "indexer_requirements" + "indexer_requirements", + "indexer_package_users" ], "consumes": [ "go-package-registry.published",