Compare commits

...
1 Commits
Author SHA1 Message Date
jschoubben 22845a5296 A module is told the name it is served under (hq 122)
A module contributes a label; the mesh joins it with the node's domains and
the provider serves the result — and the module itself was never told.
Software that must know its own address (a login redirect, a canonical URL,
an issuer) had it written into the manifest as a literal: a domain in a
definition, wrong on every other machine (ADR 0112). Found converting
grafana's keycloak login for ace, where it forced GF_SERVER_ROOT_URL and
keycloak's issuer back into manifests.

The binding for a requirement a module contributes to now carries `name`
and `internal-name` (or `names` by local name for several contributions),
and `${bound:<requirement>:name}` / `:internal-name` (`:name-<local>`) fill
files from it. Both come from the one function the provider's received
file is composed by, so the proxy and the module cannot disagree about the
name. Absent when nothing was composed, so a file asking for a name on a
node with no public domain is refused, not rendered empty.

Also: `${bound:…}` could not name a requirement answered by a node-scoped
provider on the same machine — its binding file was written (from `here`)
but the placeholders only looked at the mesh's needs. Filled from the same
answer now.
2026-09-30 00:47:22 +02:00
3 changed files with 275 additions and 28 deletions
+21
View File
@@ -70,6 +70,27 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
return out return out
} }
// withOwnNames adds a module's own composed names to what it may name from one binding:
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
// what the module is called is the mesh's statement, not the provider's.
func withOwnNames(values map[string]string, own map[string]any) {
for _, key := range []string{"name", "internal-name"} {
if v, ok := own[key].(string); ok {
values[key] = v
}
}
many, _ := own["names"].(map[string]any)
for local, raw := range many {
names, _ := raw.(map[string]any)
for _, key := range []string{"name", "internal-name"} {
if v, ok := names[key].(string); ok {
values[key+"-"+local] = v
}
}
}
}
// plainly renders a served value as a program would expect to read it. // plainly renders a served value as a program would expect to read it.
func plainly(value any) string { func plainly(value any) string {
switch v := value.(type) { switch v := value.(type) {
+120 -24
View File
@@ -574,7 +574,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
} }
found = here found = here
} }
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))) own, err := r.ownNames(m, to, with.Settings[m.Module])
if err != nil {
return nil, err
}
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -637,6 +641,35 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
} }
// And what its bindings say, for the half of a connection that is not secret. // And what its bindings say, for the half of a connection that is not secret.
known := knownFor(m, r.Needs, r.Node) known := knownFor(m, r.Needs, r.Node)
// A requirement answered on this same machine is not in r.Needs — its binding file is
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
for _, want := range m.Wants() {
if _, has := known[want]; has {
continue
}
answered, err := here(r, want, with)
if err != nil {
return nil, err
}
if answered == nil {
continue
}
local := *answered
local.For = m.Module
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
known[provision] = values
}
}
// And what the module is called through each requirement it contributes to (novox/hq
// 04-ISSUES/122) — the same composition its binding file carries.
for provision, values := range known {
own, err := r.ownNames(m, provision, with.Settings[m.Module])
if err != nil {
return nil, err
}
withOwnNames(values, own)
}
// And where this node places the directories the module declared without a path // And where this node places the directories the module declared without a path
// (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource.
dirs := dirsFor(m, with) dirs := dirsFor(m, with)
@@ -1089,21 +1122,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
// Settings reach a contribution the same way they reach a file. A route's hostname is // Settings reach a contribution the same way they reach a file. A route's hostname is
// exactly the kind of thing that differs between one mesh and the next, and a module // exactly the kind of thing that differs between one mesh and the next, and a module
// that could not have it set would have to be edited to be reused. // that could not have it set would have to be edited to be reused.
values, err := settle(m.Contributes[to], settings[m.Module], nil, values, err := r.composed(m, m.Contributes[to], settings[m.Module],
m.Module+" contributing to "+to) m.Module+" contributing to "+to)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) return nil, err
} }
reaches, err := Reaches(m, settings[m.Module])
if err != nil {
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
}
blocks, err := Endpoints(m, settings[m.Module])
if err != nil {
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
}
portOfEndpoint(values, endpointPorts(m))
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
out[to] = append(out[to], Contribution{From: m.Module, Values: values}) out[to] = append(out[to], Contribution{From: m.Module, Values: values})
} }
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
@@ -1112,24 +1135,90 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
// name always reaches the provider from here. // name always reaches the provider from here.
for _, to := range sortedKeys(m.ContributesMany) { for _, to := range sortedKeys(m.ContributesMany) {
for _, local := range sortedKeys(m.ContributesMany[to]) { for _, local := range sortedKeys(m.ContributesMany[to]) {
values, err := settle(m.ContributesMany[to][local], settings[m.Module], nil, values, err := r.composed(m, m.ContributesMany[to][local], settings[m.Module],
m.Module+" contributing "+local+" to "+to) m.Module+" contributing "+local+" to "+to)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err) return nil, err
} }
reaches, err := Reaches(m, settings[m.Module]) out[to] = append(out[to], Contribution{From: m.Module, Values: values})
if err != nil {
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
} }
blocks, err := Endpoints(m, settings[m.Module]) }
}
return out, nil
}
// composed is one contribution as its provider receives it: settled with this node's settings, its
// endpoint's port filled in, and its names composed from the label.
//
// **One function, because two readers must agree.** The provider is told the names in its received
// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122).
// Composing them twice, in two places, is how the proxy would come to serve one name while the
// module wrote another into its configuration.
func (r Resolution) composed(m Manifest, raw map[string]any, layers []Layer, what string) (
map[string]any, error) {
values, err := settle(raw, layers, nil, what)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err) return nil, fmt.Errorf("%s: %w", what, err)
}
reaches, err := Reaches(m, layers)
if err != nil {
return nil, fmt.Errorf("%s: %w", what, err)
}
blocks, err := Endpoints(m, layers)
if err != nil {
return nil, fmt.Errorf("%s: %w", what, err)
} }
portOfEndpoint(values, endpointPorts(m)) portOfEndpoint(values, endpointPorts(m))
composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks) composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks)
out[to] = append(out[to], Contribution{From: m.Module, Values: values}) return values, nil
}
// ownNames is what a module is known by through what it contributes to one requirement — the names
// the mesh composed for it, and nothing else of the contribution.
//
// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the
// mesh joins it with this node's domains, and the provider serves the result — and the module itself
// was never told. Software that must know its own address (a login redirect, a canonical URL, an
// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong
// on every other machine. `${bound:<requirement>:name}` is the answer, from the same composition the
// provider receives.
//
// Several contributions to one requirement are keyed by their local name under `names`.
func (r Resolution) ownNames(m Manifest, to string, layers []Layer) (map[string]any, error) {
pick := func(values map[string]any) map[string]any {
names := map[string]any{}
for _, key := range []string{"name", "internal-name"} {
if v, ok := values[key].(string); ok && v != "" {
names[key] = v
} }
} }
return names
}
out := map[string]any{}
if raw, ok := m.Contributes[to]; ok {
values, err := r.composed(m, raw, layers, m.Module+" contributing to "+to)
if err != nil {
return nil, err
}
for k, v := range pick(values) {
out[k] = v
}
}
if locals := m.ContributesMany[to]; len(locals) > 0 {
many := map[string]any{}
for _, local := range sortedKeys(locals) {
values, err := r.composed(m, locals[local], layers,
m.Module+" contributing "+local+" to "+to)
if err != nil {
return nil, err
}
if names := pick(values); len(names) > 0 {
many[local] = names
}
}
if len(many) > 0 {
out["names"] = many
}
} }
return out, nil return out, nil
} }
@@ -1330,14 +1419,14 @@ func sortedKeys[V any](m map[string]V) []string {
// Where it is and what the providing module said about using it. **No credential**, and the file // Where it is and what the providing module said about using it. **No credential**, and the file
// says so rather than leaving a reader to wonder whether one was meant to be there — a missing // says so rather than leaving a reader to wonder whether one was meant to be there — a missing
// field looks like a bug, and a stated absence looks like a boundary. // field looks like a bug, and a stated absence looks like a boundary.
func boundFile(n Needed, path, as string) (map[string]any, error) { func boundFile(n Needed, path, as string, own map[string]any) (map[string]any, error) {
// A record has no machine and no address. Saying so is the difference between a reader // A record has no machine and no address. Saying so is the difference between a reader
// concluding "somewhere with no address" and concluding the mesh failed to fill something in. // concluding "somewhere with no address" and concluding the mesh failed to fill something in.
where := any(n.At) where := any(n.At)
if n.ByRecord { if n.ByRecord {
where = "a record in this mesh, not a machine" where = "a record in this mesh, not a machine"
} }
body, err := json.MarshalIndent(map[string]any{ doc := map[string]any{
"binding": 1, "binding": 1,
"provision": n.Name, "provision": n.Name,
"from": n.From, "from": n.From,
@@ -1353,7 +1442,14 @@ func boundFile(n Needed, path, as string) (map[string]any, error) {
"generated": "by the mesh — do not edit; replaced whenever this changes. " + "generated": "by the mesh — do not edit; replaced whenever this changes. " +
"The credential is not here: it is sealed, in the file this module's manifest " + "The credential is not here: it is sealed, in the file this module's manifest " +
"names under `secrets`", "names under `secrets`",
}, "", " ") }
// **What this module is called through what it contributes here** (novox/hq 04-ISSUES/122):
// `name`, `internal-name`, or `names` by local name — composed exactly as the provider receives
// them. Absent when the module contributes nothing named, rather than written empty.
for key, value := range own {
doc[key] = value
}
body, err := json.MarshalIndent(doc, "", " ")
if err != nil { if err != nil {
return nil, err return nil, err
} }
+130
View File
@@ -0,0 +1,130 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it
// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on
// every other machine. It is told instead, from the same composition the provider receives.
// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file.
func selfAware(label string) Manifest {
m := labelled("board", label, 8080)
m.Requires = []string{"reverse-proxy"}
m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"}
m.Resources = []map[string]any{
{"id": "conf", "type": "file", "path": "/var/lib/board/app.conf",
"content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"},
}
return m
}
// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to
// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`).
func servingProxy() Manifest {
p := proxy()
p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}}
p.Serves = map[string]map[string]any{"reverse-proxy": {}}
return p
}
// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from
// `here` rather than from the mesh's needs — the other path a binding is written by.
func nodeProxy() Manifest {
p := proxy()
p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}}
return p
}
// onBoth is a node with a public domain and a private-network address, so both names compose.
func onBoth(domain string) Node {
n := withDomain(domain)
n.At = "anchor.internal"
return n
}
func fileAt(t *testing.T, out []map[string]any, path string) string {
t.Helper()
for _, r := range out {
if r["path"] == path {
return r["content"].(string)
}
}
t.Fatalf("nothing was declared at %s", path)
return ""
}
func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) {
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
onBoth("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
out := mustDeclare(t, got)
served := received(t, out)[0].Values
var binding map[string]any
if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil {
t.Fatal(err)
}
if binding["name"] != served["name"] || binding["name"] != "git.example.tld" {
t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"])
}
if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil {
t.Fatalf("internal name: module told %v, provider serves %v",
binding["internal-name"], served["internal-name"])
}
conf := fileAt(t, out, "/var/lib/board/app.conf")
want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n"
if conf != want {
t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want)
}
}
func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) {
// The whole point: the same definition, two machines, two names — nothing edited.
for _, domain := range []string{"example.tld", "other.example"} {
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
onBoth(domain), World{})
if err != nil {
t.Fatal(err)
}
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
if !strings.HasPrefix(conf, "root = https://git."+domain+"/") {
t.Fatalf("on %s the module wrote %q", domain, conf)
}
}
}
func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) {
// No public domain on the node: nothing composed, so no `name` — and a file asking for one is
// refused rather than rendered with a placeholder or an empty host.
m := selfAware("git")
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
if _, err := got.Declaration(Rendering{}); err == nil ||
!strings.Contains(err.Error(), `"name"`) {
t.Fatalf("a file asking for a name that was never composed was not refused: %v", err)
}
}
func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) {
m := selfAware("git")
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
got, err := Resolve(shelf(nodeProxy(), m), []string{"board"},
withDomain("example.tld"), World{})
if err != nil {
t.Fatal(err)
}
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
if !strings.HasPrefix(conf, "root = https://git.example.tld/") {
t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf)
}
}