catalogue: announce a same-node provider at the port it is published on
A co-located consumer of a `from: mesh` provision was told the port the provider module DECLARED, not the host port the mesh assigned and published it on. The same-node served facts are settled while resolving (servedHere, here()), before a bare `ports` mapping is assigned its host port, so they carried the declared number; only the cross-node path re-derived them after assignment. So the provider was published on <node>.internal:<assigned> while its own-machine consumer dialled <node>.internal:<declared>, where nothing listens — the ordinary small-mesh case, and the one the fix for issue 018 (announce the same-node provider at all) left one promise short of kept. Redirect same-node needs to the machine's assignment in Declaration, where the port map is known, exactly as plan.go already does cross-node. The publish bind is unchanged (all interfaces, scoped to the mesh by the listen's firewall rule); only the announced port is corrected. novox/hq 04-ISSUES/038 Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -141,6 +141,21 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A provider answered on this same machine is announced at the port it *declared*, because the
|
||||
// need was settled while resolving — before this machine assigned the port a bare `ports`
|
||||
// mapping is published on (novox/hq 04-ISSUES/038). Redirect those same-node needs to where the
|
||||
// machine actually publishes the provider, exactly as the cross-node path already does with the
|
||||
// provider's own assignment, so a co-located consumer is told the port that is really listening.
|
||||
servedBy := servedByModule(r.Modules)
|
||||
for i := range r.Needs {
|
||||
if r.Needs[i].From != r.Node {
|
||||
continue
|
||||
}
|
||||
if module, ok := servedBy[r.Needs[i].Name]; ok {
|
||||
r.Needs[i].Serves = atMachinePort(r.Needs[i].Serves, module, with.Ports)
|
||||
}
|
||||
}
|
||||
|
||||
// Once, from every module's listens -- not per module. A module receiving only its own ports
|
||||
// would write a rule set that closed every other module on the machine. Each module's per-node
|
||||
// exposure settings override its listens' source first (novox/hq ADR 0046).
|
||||
@@ -311,6 +326,11 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
// requirement met by the module itself.
|
||||
continue
|
||||
}
|
||||
// Answered here, so its served port needs the same redirect the resolver's
|
||||
// same-node needs got above — this fallback builds one afresh, outside r.Needs.
|
||||
if provider, ok := servedBy[to]; ok {
|
||||
here.Serves = atMachinePort(here.Serves, provider, with.Ports)
|
||||
}
|
||||
found = here
|
||||
}
|
||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)))
|
||||
@@ -838,3 +858,49 @@ func asPort(v any) (int, bool) {
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// servedByModule maps each provision offered on this node to the module that offers it.
|
||||
//
|
||||
// The same node may both provide and consume a provision, and a consumer's binding names the
|
||||
// provision, not the module behind it — so redirecting a served port to where the machine put it
|
||||
// needs this reverse lookup, from the provision back to the module whose assignment moved it.
|
||||
func servedByModule(mods []Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range mods {
|
||||
for provision := range m.Serves {
|
||||
out[provision] = m.Module
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// atMachinePort redirects a served `port` fact to where this machine actually published the
|
||||
// provider (novox/hq 04-ISSUES/038).
|
||||
//
|
||||
// **Same-node served facts are settled before the machine's port is assigned.** The resolver names
|
||||
// what a consumer must know (resolve.go's servedHere, declaration.go's here()) while resolving,
|
||||
// which happens before a bare `ports` declaration is assigned a host port — so it can only carry
|
||||
// the port the provider *declared*. The cross-node path re-derives the fact after assignment, with
|
||||
// the provider node's port map (cmd plan.go), and so already tells the truth. This closes the gap
|
||||
// for the co-located case: without it a consumer is announced `<node>.internal:<declared>` while
|
||||
// the provider is published on `<node>.internal:<assigned>`, and dials a port nothing listens on.
|
||||
//
|
||||
// A fresh map when it changes anything, so the manifest-derived value the resolver handed back is
|
||||
// never mutated under a caller that still holds it. Keyed by the *declared* port, so applying it a
|
||||
// second time is a no-op: once redirected the value is the machine port, which is not itself a key.
|
||||
func atMachinePort(serves map[string]any, module string, ports map[string]map[int]int) map[string]any {
|
||||
number, ok := asPort(serves["port"])
|
||||
if !ok {
|
||||
return serves
|
||||
}
|
||||
at, known := ports[module][number]
|
||||
if !known || at == number {
|
||||
return serves
|
||||
}
|
||||
copied := make(map[string]any, len(serves))
|
||||
for k, v := range serves {
|
||||
copied[k] = v
|
||||
}
|
||||
copied["port"] = at
|
||||
return copied
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user