diff --git a/internal/catalogue/bound_into_files.go b/internal/catalogue/bound_into_files.go index 614d1da..cf4ef56 100644 --- a/internal/catalogue/bound_into_files.go +++ b/internal/catalogue/bound_into_files.go @@ -70,6 +70,27 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str return out } +// withOwnNames adds a module's own composed names to what it may name from one binding: +// `${bound::name}` and `:internal-name`, and for several contributions to one requirement +// `:name-` / `:internal-name-`. 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. func plainly(value any) string { switch v := value.(type) { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index f6198f7..59ba361 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -574,7 +574,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri } 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 { 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. 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 // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. 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 // 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. - 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) 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}) } // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an @@ -1112,21 +1135,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, // name always reaches the provider from here. for _, to := range sortedKeys(m.ContributesMany) { 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) 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]) - 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]) - if err != nil { - return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, 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}) } } @@ -1134,6 +1147,82 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, 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 { + 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)) + composeName(values, r.PublicDomain, r.At, reaches, endpointPorts(m), blocks) + 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::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 +} + // composeName joins a contribution's label with a node's public domain, and separately with its // private one, in place (novox/hq ADR 0056). // @@ -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 // 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. -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 // concluding "somewhere with no address" and concluding the mesh failed to fill something in. where := any(n.At) if n.ByRecord { where = "a record in this mesh, not a machine" } - body, err := json.MarshalIndent(map[string]any{ + doc := map[string]any{ "binding": 1, "provision": n.Name, "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. " + "The credential is not here: it is sealed, in the file this module's manifest " + "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 { return nil, err } diff --git a/internal/catalogue/own_name_test.go b/internal/catalogue/own_name_test.go new file mode 100644 index 0000000..f77ac89 --- /dev/null +++ b/internal/catalogue/own_name_test.go @@ -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) + } +}