contributes: a module's grant carries no value where it contributed several times

ContributionsFrom settled to whichever of a module's several contributions to
one requirement sorted first, arbitrarily — the grant minted for it then
carried that contribution's label and port under a credential the OTHER
contribution's consumer never sees, and collided with that same
contribution's own entry from contributions() besides.

Confirmed live: minio's two route contributions (files-api, files) produced
three entries in route-adapter's received file — files-api twice, once
credentialed and once not, files not credentialed at all. Every
single-contribution module (gitea, keycloak, umami) already mints an unused
credential for `route` too — route never needs one, by its own
documentation — but with exactly one contribution to match there was nothing
to collide with, so it never surfaced.

Where a module contributes more than once, there is no single value to
settle on. The module still asks, still gets its one credential — a pair
credential is not a place for a label or a port anyway — and each named
contribution reaches the provider on its own, unchanged.

No cleanup needed for the secret already minted live for minio+route: the
sealed blob is a random pair credential unrelated to Values, which is
recomputed fresh on every plan/push regardless.
This commit is contained in:
2026-09-24 18:54:35 +02:00
parent 3ece1a86d7
commit 8fa5443862
2 changed files with 61 additions and 1 deletions
+18 -1
View File
@@ -1120,17 +1120,34 @@ func boundFile(n Needed, path, as string) (map[string]any, error) {
// arrangement refused is the ordinary one. A node running eight services against one database is // arrangement refused is the ordinary one. A node running eight services against one database is
// not an edge case; it is what a machine looks like. Now each consumer has its own credential and // not an edge case; it is what a machine looks like. Now each consumer has its own credential and
// there is nothing left to refuse. // there is nothing left to refuse.
//
// **One credential, even where a module contributes several times.** A module may answer one
// requirement more than once (ADR 0094's sibling for `contributes`) — an object store's data API
// and its console are two different names, not one. There is still only one `Needed` for it, one
// credential minted, one grant to settle: a pair credential is not a place to put a label or a
// port. So where several of this module's contributions reach the same requirement, none of them
// is "the" value — settling to the first, arbitrarily, would hand the grant one contribution's
// values under a credential the OTHER contribution's consumer never sees, and would collide with
// that contribution's own entry from contributions() besides. Empty values, still granted: the
// module asked, gets its credential, and each named contribution reaches the provider on its own.
func (r Resolution) ContributionsFrom(requirement, module string, settings SettingsBy) ( func (r Resolution) ContributionsFrom(requirement, module string, settings SettingsBy) (
map[string]any, bool, error) { map[string]any, bool, error) {
all, err := r.contributions(settings, nil, nil) all, err := r.contributions(settings, nil, nil)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
var mine []map[string]any
for _, g := range all[requirement] { for _, g := range all[requirement] {
if g.From == module { if g.From == module {
return g.Values, true, nil mine = append(mine, g.Values)
} }
} }
if len(mine) == 1 {
return mine[0], true, nil
}
if len(mine) > 1 {
return map[string]any{}, true, nil
}
// It contributes no payload — but a require-only consumer of a parameterless provision (one whose // It contributes no payload — but a require-only consumer of a parameterless provision (one whose
// `serves` names no consumer-supplied key: `redis-cache`, `amqp`) still ASKS for it and must be // `serves` names no consumer-supplied key: `redis-cache`, `amqp`) still ASKS for it and must be
// granted a credential. Keying "asks" on contributions alone marked those grants withdrawn // granted a credential. Keying "asks" on contributions alone marked those grants withdrawn
@@ -122,3 +122,46 @@ func TestASingleRouteStillResolvesTheOrdinaryWay(t *testing.T) {
t.Fatalf("the plain shape regressed: %+v", given) t.Fatalf("the plain shape regressed: %+v", given)
} }
} }
// ContributionsFrom is what mints the ONE pair credential a requiring module is granted
// (cmd/mesh-controller/plan.go's grantsFor) — a separate path from Declaration()'s raw file, and
// the one the two-routes test above never exercised. Where a module contributes several times,
// there is no single "the" value: settling to whichever sorts first would both misrepresent the
// grant and collide with that same contribution's own entry from contributions(), which is
// exactly the duplicate a live plan against minio surfaced (files-api appearing once with a
// credential, once without, while files got neither).
func TestContributionsFromHasNoSingleValueWhenAModuleContributesSeveralTimes(t *testing.T) {
minio, err := ParseManifest([]byte(twoRoutes))
if err != nil {
t.Fatal(err)
}
got, err := Resolve(shelf(minimalRouteProxy(), minio), []string{"route-proxy", "minio"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
values, asks, err := got.ContributionsFrom("route", "minio", nil)
if err != nil {
t.Fatal(err)
}
if !asks {
t.Fatal("minio still requires route, so it still asks")
}
if len(values) != 0 {
t.Fatalf("no single value represents two contributions, got %+v", values)
}
}
// The ordinary, single-contribution case is unchanged: exactly one match still settles to it.
func TestContributionsFromReturnsTheOneValueForAnOrdinaryContribution(t *testing.T) {
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
values, asks, err := got.ContributionsFrom("reverse-proxy", "board", nil)
if err != nil {
t.Fatal(err)
}
if !asks || values["host"] != "board" {
t.Fatalf("the ordinary single contribution should still settle to its own value: %+v", values)
}
}