diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index a41409d6..52e98383 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -289,9 +289,15 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, continue } caps, _ := inv.ProfileOf(ctx, n.Name) + // Its public domain too (novox/hq ADR 0346): a provider elsewhere serves its own composed names, + // and composes them as its own plan does, under its own domain. + domain, err := inv.PublicDomainOf(ctx, n.Name) + if err != nil { + return catalogue.World{}, err + } others = append(others, candidate{ catalogue.Node{Name: n.Name, Site: siteOf[n.Name], Capabilities: caps, - At: onNetwork[n.Name]}, theirs}) + At: onNetwork[n.Name], PublicDomain: domain}, theirs}) } offered := map[string][]catalogue.Provider{} @@ -314,8 +320,18 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, if err != nil { return catalogue.World{}, err } + // And its own composed names, as that node composes them (novox/hq ADR 0346): a name it + // does not compose leaves the key out, said under each consumer's need. + layers, err := inv.SettingsFor(ctx, o.node.Name, m.Module) + if err != nil { + return catalogue.World{}, err + } + serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers)) + if err != nil { + return catalogue.World{}, err + } offered[name] = append(offered[name], catalogue.Provider{ - Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module}) + Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut}) } } } @@ -1426,6 +1442,16 @@ func planCommand(ctx context.Context, args []string) error { // in this output would have told anybody that. for _, n := range plan.Needs { fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For) + // And a served key the provider left out, because its machine composes no name it asks for + // (novox/hq ADR 0346 §3). + for _, line := range n.LeftOut { + fmt.Printf(" %s\n", line) + } + } + // What the providers on this machine leave out of what they serve, and why: said where the remedy + // is (novox/hq ADR 0346 §3). + for _, line := range plan.ServedLeftOut(settings) { + fmt.Printf("%s: %s\n", args[0], line) } for _, line := range unboundLines(plan) { fmt.Println(line) diff --git a/cmd/mesh-controller/served_own_names_test.go b/cmd/mesh-controller/served_own_names_test.go new file mode 100644 index 00000000..3b7866ab --- /dev/null +++ b/cmd/mesh-controller/served_own_names_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// novox/hq ADR 0346, issue 624: a provider on another machine serves its consumers its own composed +// names, as that machine composes them; one it does not compose is left out, said under the need. +func TestAProviderElsewhereServesItsOwnComposedName(t *testing.T) { + open := aMesh(t) + ctx := t.Context() + forge, err := catalogue.ParseManifest([]byte(`{"module":"forge","version":"1","requires":["route"], + "provides":[{"name":"npm-package-registry","scope":"mesh"}], + "contributes":{"route":{"web":{"label":"git","port":3000}}}, + "serves":{"npm-package-registry":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/", + "public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"}}}`)) + if err != nil { + t.Fatal(err) + } + register(t, open, catalogue.Manifest{Module: "proxy", Version: "1", Provides: catalogue.FromAnywhere("route"), + Receives: map[string]string{"route": "/var/lib/proxy/routes.json"}}) + register(t, open, forge) + register(t, open, catalogue.Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"}, + Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}}) + for _, a := range [][2]string{{"anchor", "proxy"}, {"anchor", "forge"}, {"laptop", "lab"}} { + if _, err := assign(ctx, open, a[0], a[1]); err != nil { + t.Fatalf("assign %s %s: %v", a[0], a[1], err) + } + } + + if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil { + t.Fatal(err) + } + plan, _, err := planFor(ctx, open, "laptop") + if err != nil { + t.Fatal(err) + } + n := need(t, plan, "lab", "npm-package-registry") + if n.Serves["public-root"] != "https://git.example.test/api/packages/novox/npm/" || len(n.LeftOut) != 0 { + t.Fatalf("the consumer elsewhere is served the forge's public root: %v, left out %v", n.Serves, n.LeftOut) + } + + if err := open.inventory.SetPublicDomain(ctx, "anchor", ""); err != nil { + t.Fatal(err) + } + plan, _, err = planFor(ctx, open, "laptop") + if err != nil { + t.Fatal(err) + } + n = need(t, plan, "lab", "npm-package-registry") + if _, there := n.Serves["public-root"]; there { + t.Fatalf("anchor composes no public name, so the key is left out: %v", n.Serves) + } + if len(n.LeftOut) != 1 || !strings.Contains(n.LeftOut[0], `without "public-root"`) { + t.Fatalf("and the need says so: %v", n.LeftOut) + } +} diff --git a/internal/catalogue/bound.go b/internal/catalogue/bound.go index 52d2357b..8758b65e 100644 --- a/internal/catalogue/bound.go +++ b/internal/catalogue/bound.go @@ -127,6 +127,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node, identity = pm.IdentityBoundOf(n.Name) } held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity + held.LeftOut = p.LeftOut return held, true, "" } diff --git a/internal/catalogue/consumer_into_serves.go b/internal/catalogue/consumer_into_serves.go index 6757e614..74e5fbfd 100644 --- a/internal/catalogue/consumer_into_serves.go +++ b/internal/catalogue/consumer_into_serves.go @@ -143,6 +143,8 @@ func CheckServes(m Manifest) []string { problems = append(problems, fmt.Sprintf( "%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err)) } + // And the provider's own names it may serve (novox/hq ADR 0346). + problems = append(problems, checkServedOwnNames(m, provision, key, text)...) // A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own // bound has to keep the identity inside one (ADR 0225). if strings.Contains(text, "${consumer:as:dns}") { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index 8f0edaf4..3d8470f6 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -2039,7 +2039,13 @@ func (r Resolution) servedOnThisMachine(provision string, with Rendering) (map[s if err != nil { return nil, false, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) } - return settled, true, nil + // And the provider's own names, as this machine composes them (novox/hq ADR 0346); one it does + // not compose leaves its key out, said by the plan on this machine (ServedLeftOut). + named, _, err := r.ServedOwnNames(m, provision, settled, WithDefaults(m, with.Settings[m.Module])) + if err != nil { + return nil, false, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) + } + return named, true, nil } return nil, false, nil } diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 2b5af88e..4346c12c 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -111,6 +111,9 @@ type Provider struct { // (novox/hq to-be 23), and the pair is what tells the holder of a seat apart from another module // providing the same thing (ADR 0110). Module string + // LeftOut says each served key left out of Serves because that node composes no name it asks + // for (novox/hq ADR 0346 §3): said under each consumer's need. + LeftOut []string } // Held is a claim somebody already has, used for the scopes wider than one node. @@ -230,6 +233,9 @@ type Needed struct { // state, not a consumer missing its key. Set by the plan, which is the only layer that knows a // licence's manager; empty for every consumer. Manager bool + // LeftOut says each served key the provider left out, because its machine composes no name the + // value asks for (novox/hq ADR 0346 §3). + LeftOut []string // Module is the providing module on From, empty for a need answered by a record or by nothing // that serves it. A provider is a (node, module) pair (novox/hq to-be 23), and a binding to data // is a binding to the pair (ADR 0232). @@ -582,7 +588,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world bound = pm.IdentityBoundOf(want) } needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module, - Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound}) + Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound, LeftOut: p.LeftOut}) } switch { case world.Unchecked: diff --git a/internal/catalogue/served_own_names.go b/internal/catalogue/served_own_names.go new file mode 100644 index 00000000..f374e911 --- /dev/null +++ b/internal/catalogue/served_own_names.go @@ -0,0 +1,173 @@ +package catalogue + +import ( + "fmt" + "sort" + "strings" +) + +// A provider may tell its consumers its own composed names (novox/hq ADR 0346, issue 624). +// +// **The name is already the mesh's.** A module contributes a label to a requirement — a route — and +// the mesh joins it with the providing machine's domains; the module reads the result in its own files +// as `${bound::name}` (novox/hq 04-ISSUES/122). Its consumers could not be told it: a +// served value named only the consumer (ADR 0202) and settings. The forge's package registry writes +// every tarball at its public name, and a consumer that must trust that name had no source for it but a +// second copy kept by hand. So a served value may say `${bound::}` for a requirement +// the provider contributes to, and the mesh fills it from the same composition the provider's own files +// and its proxy receive. +// +// **A name the providing machine does not compose leaves the key out** (ADR 0346 §3): a machine with +// no public domain has no public name to give, and an empty value or a refusal would be worse than a +// consumer told nothing — a refused provider machine takes every provision it offers down with it. The +// plan says which key was left out, and why, on the provider's machine and under each consumer's need. + +// ownNameFacts are what a served value may name of the provider's own composition: the names, as its +// own files read them (withOwnNames). +var ownNameFacts = []string{"name", "internal-name"} + +// requirementsContributedTo is every requirement m contributes to, sorted. +func requirementsContributedTo(m Manifest) []string { + seen := map[string]bool{} + for to := range m.Contributes { + seen[to] = true + } + for to := range m.ContributesMany { + seen[to] = true + } + out := make([]string, 0, len(seen)) + for to := range seen { + out = append(out, to) + } + sort.Strings(out) + return out +} + +// ownNameKeys is every name a served value of m may say for one requirement it contributes to: +// `name` and `internal-name`, and `name-` and `internal-name-` for each local name it +// contributes under. +func ownNameKeys(m Manifest, to string) []string { + keys := append([]string{}, ownNameFacts...) + for _, local := range sortedKeys(m.ContributesMany[to]) { + for _, fact := range ownNameFacts { + keys = append(keys, fact+"-"+local) + } + } + return keys +} + +// ServedOwnNames fills the provider's own composed names into what it serves one provision's +// consumers, from this resolution's composition of the provider's machine (ADR 0346). +// +// A value naming a name this machine does not compose is left out, with its key, and said. The map is +// copied only when something changes, as ServedTo does: the caller's may be the manifest's. +func (r Resolution) ServedOwnNames(m Manifest, provision string, serves map[string]any, layers []Layer) ( + map[string]any, []string, error) { + var asking []string + for _, key := range sortedAnyKeys(serves) { + if text, ok := serves[key].(string); ok && bound.MatchString(text) { + asking = append(asking, key) + } + } + if len(asking) == 0 { + return serves, nil, nil + } + own := map[string]map[string]string{} + for _, to := range requirementsContributedTo(m) { + names, err := r.ownNames(m, to, layers) + if err != nil { + return nil, nil, err + } + values := map[string]string{} + withOwnNames(values, names) + own[to] = values + } + out := make(map[string]any, len(serves)) + for k, v := range serves { + out[k] = v + } + var said []string + for _, key := range asking { + text := serves[key].(string) + var missing string + filled := bound.ReplaceAllStringFunc(text, func(token string) string { + parts := bound.FindStringSubmatch(token) + if value := own[parts[1]][parts[2]]; value != "" && parts[3] == "" { + return value + } + if missing == "" { + missing = token + } + return token + }) + if missing != "" { + delete(out, key) + said = append(said, fmt.Sprintf( + "%s serves %s without %q: it names %s, and %s composes no such name for %s (it has no "+ + "public domain, or the endpoint is not reached publicly) (novox/hq ADR 0346)", + m.Module, provision, key, missing, orHere(r.Node), m.Module)) + continue + } + out[key] = filled + } + return out, said, nil +} + +func orHere(node string) string { + if node == "" { + return "this machine" + } + return node +} + +// ServedLeftOut is what the providers in this node's own set leave out of what they serve, because +// this machine composes no name a served value asks for (ADR 0346 §3): said on the provider's machine, +// where the remedy is. +func (r Resolution) ServedLeftOut(settings SettingsBy) []string { + var said []string + for _, m := range r.Modules { + for _, provision := range sortedServes(m.Serves) { + _, lines, err := r.ServedOwnNames(m, provision, m.Serves[provision], WithDefaults(m, settings[m.Module])) + if err != nil { + said = append(said, fmt.Sprintf("%s serves %s, and its own names could not be composed: %v", + m.Module, provision, err)) + continue + } + said = append(said, lines...) + } + } + return said +} + +// checkServedOwnNames refuses a served `${bound:…}` that names a requirement the module does not +// contribute to, a fact other than its own names, or a local name it does not contribute under; and +// one with a default, since a served value is left out rather than given one (ADR 0346 §3, §4). +func checkServedOwnNames(m Manifest, provision, key, text string) []string { + var problems []string + contributes := requirementsContributedTo(m) + for _, parts := range bound.FindAllStringSubmatch(text, -1) { + to, fact, fallback := parts[1], parts[2], parts[3] + says := fmt.Sprintf("%s serves %s, and the value it serves as %q says %s", m.Module, provision, key, parts[0]) + switch { + case fallback != "": + problems = append(problems, says+": a served name takes no default; one the machine does not "+ + "compose leaves the key out (novox/hq ADR 0346)") + case !oneOf(contributes, to): + problems = append(problems, fmt.Sprintf( + "%s, and %s contributes to %s: a served value names only the provider's own names "+ + "(novox/hq ADR 0346)", says, m.Module, orNothing(quoted(contributes)))) + case !oneOf(ownNameKeys(m, to), fact): + problems = append(problems, fmt.Sprintf("%s, and what %s is called through %s is %s (novox/hq ADR 0346)", + says, m.Module, to, strings.Join(ownNameKeys(m, to), ", "))) + } + } + return problems +} + +func quoted(values []string) []string { + out := make([]string, len(values)) + for i, v := range values { + out[i] = fmt.Sprintf("%q", v) + } + return out +} diff --git a/internal/catalogue/served_own_names_test.go b/internal/catalogue/served_own_names_test.go new file mode 100644 index 00000000..360deff1 --- /dev/null +++ b/internal/catalogue/served_own_names_test.go @@ -0,0 +1,149 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// novox/hq ADR 0346, issue 624: the forge's package registry writes every tarball at its public name, +// and the lab, its consumer, must trust that name and no other. The name is the mesh's own composition +// of the forge's route, which the forge's files read as ${bound:route:name-web}; a consumer can now be +// served it. + +const aRegistryForge = `{"module":"forge","version":"1","requires":["route"], + "provides":[{"name":"npm-package-registry","scope":"mesh"}], + "contributes":{"route":{"web":{"label":"git","port":3000}}}, + "serves":{"npm-package-registry":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/", + "public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"}}}` + +func aForgeManifest(t *testing.T) Manifest { + t.Helper() + m, err := ParseManifest([]byte(aRegistryForge)) + if err != nil { + t.Fatal(err) + } + return m +} + +func TestAProviderServesItsOwnComposedName(t *testing.T) { + m := aForgeManifest(t) + r := Resolution{Node: "home", PublicDomain: "example.org"} + got, said, err := r.ServedOwnNames(m, "npm-package-registry", m.Serves["npm-package-registry"], nil) + if err != nil { + t.Fatal(err) + } + if got["public-root"] != "https://git.example.org/api/packages/novox/npm/" || len(said) != 0 { + t.Fatalf("the public root is the route's composed name: %v, said %v", got, said) + } + if got["npm-path"] != "/api/packages/novox/npm/" || got["port"] != float64(3000) { + t.Fatalf("the rest is served as it was: %v", got) + } + if m.Serves["npm-package-registry"]["public-root"] != "https://${bound:route:name-web}/api/packages/novox/npm/" { + t.Fatal("the manifest's own map was rewritten") + } +} + +func TestANameTheMachineDoesNotComposeLeavesTheKeyOutAndSaysSo(t *testing.T) { + m := aForgeManifest(t) + r := Resolution{Node: "home"} // no public domain + got, said, err := r.ServedOwnNames(m, "npm-package-registry", m.Serves["npm-package-registry"], nil) + if err != nil { + t.Fatal(err) + } + if _, there := got["public-root"]; there { + t.Fatalf("a name not composed is left out, never written as the placeholder or empty: %v", got) + } + if got["npm-path"] == nil { + t.Fatalf("only the key that names it is left out: %v", got) + } + if len(said) != 1 || !strings.Contains(said[0], `forge serves npm-package-registry without "public-root"`) || + !strings.Contains(said[0], "home composes no such name") { + t.Fatalf("said: %v", said) + } + if lines := (Resolution{Node: "home", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 1 { + t.Fatalf("the plan on the provider's machine says it: %v", lines) + } + if lines := (Resolution{Node: "home", PublicDomain: "example.org", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 0 { + t.Fatalf("nothing to say when the name is composed: %v", lines) + } +} + +// On the provider's own machine the consumer is served the same name (ADR 0346 §2). +func TestAConsumerBesideTheProviderIsServedItsName(t *testing.T) { + m := aForgeManifest(t) + consumer := Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"}, + Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}} + node := workstation() + node.PublicDomain = "example.org" + got, err := Resolve(shelf(minimalRouteProxy(), m, consumer), []string{"route-proxy", "forge", "lab"}, node, World{}) + if err != nil { + t.Fatal(err) + } + served, answered, err := got.servedOnThisMachine("npm-package-registry", Rendering{}) + if err != nil || !answered { + t.Fatalf("answered %v: %v", answered, err) + } + if served["public-root"] != "https://git.example.org/api/packages/novox/npm/" { + t.Fatalf("served beside the provider: %v", served) + } + out, err := got.Declaration(Rendering{}) + if err != nil { + t.Fatal(err) + } + var binding string + for _, r := range out { + if r["path"] == "/var/lib/lab/package-registry.json" { + binding, _ = r["content"].(string) + } + } + if !strings.Contains(binding, `"public-root": "https://git.example.org/api/packages/novox/npm/"`) && + !strings.Contains(binding, `"public-root":"https://git.example.org/api/packages/novox/npm/"`) { + t.Fatalf("the consumer's binding carries the name:\n%s", binding) + } + if strings.Contains(binding, "${bound:") { + t.Fatalf("a placeholder reached the consumer:\n%s", binding) + } +} + +// From another machine, the provider's served values carry what that machine composed, and a key it +// left out is said on the consumer's need (ADR 0346 §2, §3). +func TestAConsumerElsewhereIsToldWhatTheProviderLeftOut(t *testing.T) { + consumer := Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"}, + Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}} + node := workstation() + node.At = "workstation.internal" + world := World{Offered: map[string][]Provider{"npm-package-registry": {{Node: "home", At: "home.internal", + Module: "forge", Serves: map[string]any{"port": 3000}, + LeftOut: []string{`forge serves npm-package-registry without "public-root"`}}}}} + got, err := Resolve(shelf(aForgeManifest(t), consumer), []string{"lab"}, node, world) + if err != nil { + t.Fatal(err) + } + for _, n := range got.Needs { + if n.Name == "npm-package-registry" { + if len(n.LeftOut) != 1 { + t.Fatalf("the need carries what was left out: %+v", n) + } + return + } + } + t.Fatalf("no need: %+v", got.Needs) +} + +func TestAServedNameIsCheckedWhenTheDefinitionIsRead(t *testing.T) { + for bad, want := range map[string]string{ + `"public-root":"https://${bound:route:name-api}/x"`: "what forge is called through route is name, internal-name, name-web, internal-name-web", + `"public-root":"https://${bound:postgres-database:name}/x"`: `forge contributes to "route"`, + `"public-root":"https://${bound:route:name-web|default:x}/x"`: "takes no default", + `"public-root":"https://${bound:route:at}/x"`: "what forge is called through route", + } { + raw := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, bad, 1) + _, err := ParseManifest([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: want a refusal saying %q, got %v", bad, want, err) + } + } + if _, err := ParseManifest([]byte(aRegistryForge)); err != nil { + t.Fatalf("its own name is accepted: %v", err) + } +}