diff --git a/internal/catalogue/bound_into_files.go b/internal/catalogue/bound_into_files.go index cf4ef56..65192da 100644 --- a/internal/catalogue/bound_into_files.go +++ b/internal/catalogue/bound_into_files.go @@ -46,7 +46,7 @@ func boundUsed(content string) [][2]string { // Three facts the mesh states about any provision, plus whatever the provider said it serves. A // module may not reach a binding it does not have — the same boundary as a secret, for the same // reason. -func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string { +func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) { out := map[string]map[string]string{} for _, want := range m.Wants() { for i := range needs { @@ -54,12 +54,20 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str if n.Name != want || n.For != m.Module { continue } + as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)) values := map[string]string{ "at": n.At, "from": n.From, - "as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)), + "as": as, } - for key, value := range n.Serves { + // What the provider derives for this consumer rather than for all of them + // (novox/hq ADR 0188). Filled here, the one place a provision and the module + // requiring it are both in hand. + served, err := ServedTo(n.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err) + } + for key, value := range served { // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, // which is what a float would write and what a connection string would refuse. values[key] = plainly(value) @@ -67,7 +75,7 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str out[want] = values } } - return out + return out, nil } // withOwnNames adds a module's own composed names to what it may name from one binding: diff --git a/internal/catalogue/consumer_into_serves.go b/internal/catalogue/consumer_into_serves.go new file mode 100644 index 0000000..5d45f5e --- /dev/null +++ b/internal/catalogue/consumer_into_serves.go @@ -0,0 +1,290 @@ +package catalogue + +import ( + "fmt" + "regexp" + "sort" + "strings" +) + +// What a provider derives for one consumer, said once in the provider's definition and delivered +// to both ends (novox/hq ADR 0188, issue 124). +// +// A `serves` block is otherwise literal: the same values for every consumer. Where the provider +// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking, +// and before this the mesh had no channel for it. The provider recomputed it in its own code and +// every consumer transcribed it into its own definition by hand, which is a copy of somebody +// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months. +// +// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.** +// The only fact a served value may name is the identity the mesh itself minted for the consumer, +// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants +// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a +// served value is a string. + +// consumerFact is `${consumer:}` or `${consumer::}`. +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +// consumerFacts are what a served value may name about the consumer it is being derived for. +// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose, +// so it is the one thing the mesh can hand to a provider without either end guessing. +var consumerFacts = []string{"as"} + +// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own +// identifier with its separator written `-` instead of `_` — the whole of the difference between +// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept. +var consumerAlphabets = []string{"dns"} + +// ServedTo fills a provider's served values for one consumer. +// +// `as` is the identity the mesh minted for that consumer — the same string it is told to present +// as a login. Values with no placeholder are returned exactly as they were, and a block with no +// placeholder at all is returned unchanged, so this costs nothing for the providers that derive +// nothing. +// +// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider +// stated outright, and is left alone. +func ServedTo(serves map[string]any, as string) (map[string]any, error) { + if len(serves) == 0 { + return serves, nil + } + var out map[string]any + for _, key := range sortedAnyKeys(serves) { + text, ok := serves[key].(string) + if !ok || !strings.Contains(text, "${consumer:") { + continue + } + filled, err := consumerInto(text, as) + if err != nil { + return nil, fmt.Errorf("the value served as %q: %w", key, err) + } + if out == nil { + // Copied only once something actually changes: the caller's map is the manifest's, + // and a provider that derives nothing must not have it rewritten underneath it. + out = make(map[string]any, len(serves)) + for k, v := range serves { + out[k] = v + } + } + out[key] = filled + } + if out == nil { + return serves, nil + } + return out, nil +} + +// consumerInto replaces every `${consumer:…}` in one value. +// +// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through, +// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name, +// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}` +// is refused by (boundInto). +func consumerInto(value, as string) (string, error) { + var failed error + out := consumerFact.ReplaceAllStringFunc(value, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + fact, alphabet := parts[1], parts[2] + if fact != "as" { + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts)) + } + return match + } + switch alphabet { + case "": + return as + case "dns": + return asDNSLabel(as) + default: + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets)) + } + return match + } + }) + if failed != nil { + return "", failed + } + return out, nil +} + +// asDNSLabel writes a minted identity as a DNS label. +// +// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and +// already short enough for the tightest backend they reach (CheckIdentity, twenty characters). So +// this is the separator and nothing else — no lower-casing of what is already lower case, no +// truncation to a limit the identity is already inside, no padding of a name that is already long +// enough. Each of those would be the mesh guessing at a rule it has not been given. +func asDNSLabel(as string) string { + return strings.ReplaceAll(as, "_", "-") +} + +// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not +// have, when the definition is parsed rather than when a consumer is resolved. +// +// A provision nobody consumes yet still has its rule read: a definition that would be refused the +// first time somebody required it is a definition that is wrong now. +func CheckServes(m Manifest) []string { + var problems []string + for _, provision := range sortedServes(m.Serves) { + for _, key := range sortedAnyKeys(m.Serves[provision]) { + text, ok := m.Serves[provision][key].(string) + if !ok { + continue + } + // A probe identity, because what is checked is the shape of the statement and not + // what any consumer is called. + if _, err := consumerInto(text, "mesh_node_module"); err != nil { + problems = append(problems, fmt.Sprintf( + "%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err)) + } + } + } + return problems +} + +func sortedServes(serves map[string]map[string]any) []string { + out := make([]string, 0, len(serves)) + for k := range serves { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedAnyKeys(values map[string]any) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// derivedFor is what the provider on this machine derives for one consumer of one provision +// (novox/hq ADR 0188). +// +// Settled first, then derived: an operator may set a prefix on what the provider serves and the +// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the +// consumer are returned — the rest of a `serves` block is the same for every consumer and is +// already in the provider's own definition, so repeating it here would be a second copy to go +// stale. +// +// The first module in the resolved order that says it serves the provision answers, which is the +// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is +// not an error: a contribution can reach a machine whose provider is a record or an adapter, and +// then there is nothing derived to tell. +func (r Resolution) derivedFor(provision, as string, settings SettingsBy) (map[string]any, error) { + for _, m := range r.Modules { + serves, said := m.Serves[provision] + if !said { + continue + } + var names map[string]any + for key, value := range serves { + if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") { + if names == nil { + names = map[string]any{} + } + names[key] = value + } + } + if names == nil { + return nil, nil + } + settled, err := Settle(names, settings[m.Module]) + if err != nil { + return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) + } + derived, err := ServedTo(settled, as) + if err != nil { + return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err) + } + return derived, nil + } + return nil, nil +} + +// notTranscribed refuses a consumer's file that writes out the value its provider derives for it, +// instead of asking for it (novox/hq ADR 0188, issue 124). +// +// **What would have caught the one wrong instance.** The object store's three consumers each wrote +// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and +// nothing compared it to what the provider would actually create: the module would have +// authenticated successfully and been refused on every object, which reads like a credential fault +// and is not one. It looked authoritative for months. +// +// The test is exact and costs one string search: a definition whose file already contains the +// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a +// coincidence — a derived value carries the identity the mesh minted for this very consumer on +// this very machine, which nothing else would spell out — and it cannot be checked afterwards, +// because after substitution every consumer's file contains it legitimately. +// +// Only values that actually name the consumer are judged. A provider that serves a constant under +// the same key serves the same constant to everyone, and a consumer repeating it is redundant +// rather than wrong. +func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok || content == "" { + return nil + } + for _, provision := range sortedKnown(known) { + values := known[provision] + identity := values["as"] + if identity == "" { + continue + } + for _, key := range sortedStringKeys(values) { + if key == "as" { + // The login is not derived from itself, and a consumer that must present it in a + // connection string legitimately has it from `${bound:…}` — which is what it will + // be after substitution, so this would judge the substitution, not the module. + continue + } + value := values[key] + if value == "" || !namesTheConsumer(value, identity) { + continue + } + if !strings.Contains(content, value) { + continue + } + return fmt.Errorf( + "%s writes %q into %v, and that is exactly what %s derives for it — a definition "+ + "keeping its own copy of somebody else's naming rule is one that can disagree "+ + "with it, silently. Say ${bound:%s:%s} and be told", + module, value, resource["id"], provision, provision, key) + } + } + return nil +} + +// namesTheConsumer is whether a derived value was built from this consumer's identity — in the +// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived +// from it, whatever else it may be. +func namesTheConsumer(value, identity string) bool { + return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity)) +} + +func sortedKnown(known map[string]map[string]string) []string { + out := make([]string, 0, len(known)) + for k := range known { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedStringKeys(values map[string]string) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index f302e0e..0b06762 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -628,7 +628,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string, if err != nil { return nil, err } - file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own) + as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)) + // What the provider derives for THIS consumer, filled here where the consumer is + // known (novox/hq ADR 0188). The same fill knownFor does below, so the binding file + // and the module's `${bound:…}` substitutions cannot say different things. + told := *found + told.Serves, err = ServedTo(told.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err) + } + file, err := boundFile(told, m.Binds[to], as, own) if err != nil { return nil, err } @@ -694,7 +703,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string, return nil, err } // And what its bindings say, for the half of a connection that is not secret. - known := knownFor(m, r.Needs, r.Node) + known, err := knownFor(m, r.Needs, r.Node) + if err != nil { + return nil, err + } // 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. @@ -711,7 +723,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string, } local := *answered local.For = m.Module - for provision, values := range knownFor(m, []Needed{local}, r.Node) { + here, err := knownFor(m, []Needed{local}, r.Node) + if err != nil { + return nil, err + } + for provision, values := range here { known[provision] = values } } @@ -736,6 +752,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string, // And the machine underneath, which no binding of its own can tell it. thisMachine := machineFacts(r, with.Names, with.MeshRange) + // **A definition that already holds the answer transcribed it** (novox/hq ADR 0188). + // Judged over what the module itself declares, and before anything is substituted: the + // mesh's own generated files — the binding, the contributions — legitimately carry the + // derived value, and after substitution so does every consumer's file, so this is the one + // moment the two can be told apart. + for _, own := range m.Resources { + if err := notTranscribed(own, known, m.Module); err != nil { + return nil, err + } + } + // Which of this module's files carry a secret, for the rule that a container may not read // one of them as its environment without saying so (ADR 0086, issue 041). secretFiles := secretFilesOf(resources) @@ -1100,6 +1127,19 @@ type Contribution struct { // requirement's name — everything providing `reverse-proxy` understands the same shape, which // is what makes swapping one for another cost nothing. Values map[string]any `json:"values"` + // Derived is what this provider's own definition said it derives for this consumer, already + // derived (novox/hq ADR 0188). + // + // **The provider is told, rather than recomputing it.** A served value may name the consumer's + // identity — a bucket named for who is asking, a database prefixed with it — and before this + // the rule lived twice: once in the provisioner's code, once transcribed into every consumer's + // definition. The mesh fills the provider's own statement here and delivers the same filled + // value to the consumer, so the two cannot disagree: there is no second computation to + // disagree with. + // + // Only the keys that are per-consumer. The rest of what the provider serves is the same for + // everyone and is in its own definition, where it already is. + Derived map[string]any `json:"derived,omitempty"` } // grantPath is where one consumer's sealed credential lands on the providing machine. @@ -1191,12 +1231,17 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, // told about it and withdraws the login on its next pass. continue } + as := holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local) + derived, err := r.derivedFor(g.Provision, as, settings) + if err != nil { + return nil, err + } out[g.Provision] = append(out[g.Provision], Contribution{ - From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, + From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived, // One holder per local name: the identity the consumer is known by, and the local name // after it where the module keeps several (ADR 0094). Not a login any backend checks — // a secret is not a login — so the identity limit does not apply to the suffix. - As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local), + As: as, Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)), }) if granted[g.Provision] == nil { diff --git a/internal/catalogue/derived_for_consumer_test.go b/internal/catalogue/derived_for_consumer_test.go new file mode 100644 index 0000000..67b4573 --- /dev/null +++ b/internal/catalogue/derived_for_consumer_test.go @@ -0,0 +1,297 @@ +package catalogue + +import ( + "encoding/json" + "strings" + "testing" +) + +// What a provider derives for each consumer, said once and delivered to both ends +// (novox/hq ADR 0188, issue 124). +// +// The failure these are written against: the object store's provisioner derived each consumer's +// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the +// consumer which bucket that was — so all three consumers wrote the answer into their own +// definitions by hand. Two were right. One named a predecessor's bucket and would have +// authenticated successfully and been refused on every object. Each of them also named the +// machine the module happens to run on, which a definition may not do. + +// store is an object store in the shape minio has: it serves a region and a port to everyone, and +// a bucket named for whoever is asking. +func store() Manifest { + return Manifest{ + Module: "store", Version: "1", + Provides: FromAnywhere("s3-bucket"), + Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}}, + Serves: map[string]map[string]any{"s3-bucket": { + "region": "eu-west", + "bucket": "${consumer:as:dns}", + }}, + Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"}, + Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "store", "ports": []any{"9000"}, + }}, + } +} + +// files is a consumer that writes the bucket into its own configuration — which is the thing it +// could not do before, and had to transcribe. +func files() Manifest { + return Manifest{ + Module: "files", Version: "1", Slug: "files", + Requires: []string{"s3-bucket"}, + Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"}, + Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"}, + Resources: []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600", + "content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n", + }}, + } +} + +// pics is a second consumer of the same provider on the same machine: two derivations, neither +// the other's. +func pics() Manifest { + return Manifest{ + Module: "pics", Version: "1", Slug: "pics", + Requires: []string{"s3-bucket"}, + Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"}, + Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"}, + Resources: []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600", + "content": "BUCKET=${bound:s3-bucket:bucket}\n", + }}, + } +} + +// The three places the derived value lands must agree, because agreeing is the whole point: the +// consumer's own file, the binding it reads as JSON, and the provider's contributions entry. +func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) { + r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{Grants: []Grant{{ + Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}) + if err != nil { + t.Fatal(err) + } + + // The mesh minted this identity for the consumer; the bucket is that identity as a DNS label. + // Derived here with the mesh's own function, so the test cannot agree with a wrong rule. + as := ConsumerIdentity("workstation", IdentitySource("files", "files")) + want := strings.ReplaceAll(as, "_", "-") + if want == as || !strings.Contains(as, "_") { + t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as) + } + + env := fileNamed(out, "files.env") + if env == nil { + t.Fatalf("the consumer was given no file: %v", out) + } + if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") { + t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want) + } + + binding := fileNamed(out, "files.bound-s3-bucket") + if binding == nil { + t.Fatalf("the consumer was given no binding: %v", out) + } + var said struct { + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil { + t.Fatal(err) + } + if said.Serves["bucket"] != want { + t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want) + } + // And what is the same for everybody is still the same for everybody. + if said.Serves["region"] != "eu-west" { + t.Errorf("the binding lost what the provider serves to all: %v", said.Serves) + } + + given := storeGrants(t, out) + if len(given) != 1 { + t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given) + } + if given[0].Derived["bucket"] != want { + t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+ + "the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want) + } + // Only the per-consumer half. The region is the same for everyone and is already in the + // provider's own definition; repeating it here would be a copy to go stale. + if _, carried := given[0].Derived["region"]; carried { + t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived) + } +} + +// Two consumers of one provider on one machine get two buckets, and neither gets the other's. +func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) { + r, err := Resolve(shelf(store(), files(), pics()), + []string{"store", "files", "pics"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{Grants: []Grant{ + {Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk"}, + {Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics", + Values: map[string]any{}, Sealed: "c2VhbGVk"}, + }}) + if err != nil { + t.Fatal(err) + } + forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-") + forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-") + if forFiles == forPics { + t.Fatal("the two consumers were given the same identity; this test proves nothing") + } + if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") { + t.Errorf("files was not given its own bucket:\n%s", got) + } + if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") { + t.Errorf("pics was not given its own bucket:\n%s", got) + } + var buckets []any + for _, g := range storeGrants(t, out) { + buckets = append(buckets, g.Derived["bucket"]) + } + if len(buckets) != 2 || buckets[0] == buckets[1] { + t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets) + } +} + +// An operator may still set what the provider serves, and the mesh still derives the rest: the +// setting is laid on first, then the consumer's half is filled. +func TestASettingComposesWithADerivedValue(t *testing.T) { + r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + out, err := r.Declaration(Rendering{ + Settings: SettingsBy{"store": {{From: "the operator", + Values: map[string]any{"bucket": "team-${consumer:as:dns}"}}}}, + Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk"}}, + }) + if err != nil { + t.Fatal(err) + } + want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-") + if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") { + t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want) + } + if given := storeGrants(t, out); given[0].Derived["bucket"] != want { + t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want) + } +} + +// A fact or an alphabet the mesh does not have is refused where the definition is, not where a +// consumer happens to be resolved — and the refusal says what may be said instead. +func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) { + for _, c := range []struct{ value, says string }{ + {"${consumer:node}", "as"}, + {"${consumer:as:punycode}", "dns"}, + } { + m := store() + m.Serves["s3-bucket"]["bucket"] = c.value + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + _, err = ParseManifest(raw) + if err == nil { + t.Fatalf("%s was accepted", c.value) + } + if !strings.Contains(err.Error(), c.value) { + t.Errorf("the refusal of %s does not quote it: %v", c.value, err) + } + if !strings.Contains(err.Error(), c.says) { + t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err) + } + } +} + +// `dns` is checked against an identity the mesh actually mints, not an invented string. +func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) { + as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud")) + if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil { + t.Fatalf("the mesh would not mint this identity at all: %v", err) + } + label := asDNSLabel(as) + if strings.Contains(label, "_") { + t.Errorf("%q is not a DNS label", label) + } + if strings.ReplaceAll(label, "-", "_") != as { + t.Errorf("%q is not %q with its separator rewritten", label, as) + } +} + +// The check that would have caught the one wrong instance: a consumer that writes the derived +// value into its own definition instead of asking for it is refused, whether it transcribed the +// right answer or a predecessor's. +func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) { + as := ConsumerIdentity("workstation", IdentitySource("files", "files")) + transcribed := strings.ReplaceAll(as, "_", "-") + + m := files() + m.Resources = []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600", + // Exactly what the provider will create — correct today, and a copy of a rule that is + // not this module's. + "content": "BUCKET=" + transcribed + "\n", + }} + r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + _, err = r.Declaration(Rendering{Grants: []Grant{{ + Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}) + if err == nil { + t.Fatal("a definition holding its own copy of the provider's naming rule was accepted") + } + if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") { + t.Errorf("the refusal does not say what to write instead: %v", err) + } + + // And a constant the provider serves to everyone is not a transcription: repeating it is + // redundant, not wrong, and refusing it would be the mesh policing style. + m.Resources = []map[string]any{{ + "id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600", + "content": "REGION=eu-west\n", + }} + r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{}) + if err != nil { + t.Fatal(err) + } + if _, err := r.Declaration(Rendering{Grants: []Grant{{ + Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files", + Values: map[string]any{}, Sealed: "c2VhbGVk", + }}}); err != nil { + t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err) + } +} + +func storeGrants(t *testing.T, out []map[string]any) []Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/var/lib/store/grants/mesh.json" { + continue + } + var parsed struct { + Given []Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("the provider was given no contributions file: %v", out) + return nil +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 93db44a..8380cd8 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -1360,6 +1360,10 @@ func ParseManifest(raw []byte) (Manifest, error) { "%s serves %q to whoever requires it, and does not provide it", m.Module, to)) } } + // A served value may be derived for the consumer it is served to (novox/hq ADR 0188). Read + // here, where the definition is, rather than when somebody first requires it: a rule that + // would be refused at the first consumer is wrong from the moment it is written. + problems = append(problems, CheckServes(m)...) for to, where := range m.Binds { if !placedOrAbsolute(where) { problems = append(problems, fmt.Sprintf(