The one global 20-character bound made every consumer pay an object
store's key length, even for provisions that keep no name, and a single
overflow refused the provider's whole declaration. An offer now states
its own bound (identity: {max, in} or false); unsaid, a provider told its
consumers keeps 20 and one told nothing keeps none. module check judges
every identity on the longest machine name before merge, and a provider
leaves an overflowing consumer out of its grants and composes, with the
consumer named by push, plan and status (ADR 0225).
191 lines
9.5 KiB
Go
191 lines
9.5 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Each test names the decision it defends: novox/hq ADR 0225, which refines ADR 0049 after issue 263.
|
|
|
|
// The provision a module requires sets the bound on its identity, not the tightest backend anywhere.
|
|
func TestABoundIsTheProvisionsOwn(t *testing.T) {
|
|
store := Manifest{Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
|
|
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
|
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}
|
|
database := Manifest{Module: "db", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
|
Identity: &OfferIdentity{Max: 63, In: "a PostgreSQL role"}}},
|
|
Receives: map[string]string{"postgres-database": "/var/lib/mesh/db/mesh.json"}}
|
|
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 || b.In != "an S3 access key" {
|
|
t.Errorf("an object store's stated bound was not taken: %+v", b)
|
|
}
|
|
if b := database.IdentityBoundOf("postgres-database"); b.Max != 63 {
|
|
t.Errorf("a database's stated bound was not taken: %+v", b)
|
|
}
|
|
// mesh_workstation_keycloak is 25: refused by the object store, accepted by the database.
|
|
if CheckIdentityWithin("workstation", "keycloak", store.IdentityBoundOf("s3-bucket")) == nil {
|
|
t.Error("a 25-character identity fit a 20-character access key")
|
|
}
|
|
if err := CheckIdentityWithin("workstation", "keycloak", database.IdentityBoundOf("postgres-database")); err != nil {
|
|
t.Errorf("a database consumer paid the object store's limit: %v", err)
|
|
}
|
|
}
|
|
|
|
// What an offer leaves unsaid follows from whether its provider can keep a name at all.
|
|
func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) {
|
|
// Told nothing about its consumers — no receives, nothing served from their identity: no bound.
|
|
// The resolver provision is exactly this, and its consumers paid an object store's limit (263).
|
|
resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}}
|
|
if b := resolver.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
|
t.Errorf("a provision that is told nothing of its consumers bounds them: %+v", b)
|
|
}
|
|
// Told each consumer and silent about its backend: the old global bound, not none.
|
|
told := Manifest{Module: "told", Provides: []Offer{{Name: "thing", Scope: ScopeMesh}},
|
|
Receives: map[string]string{"thing": "/var/lib/mesh/told/mesh.json"}}
|
|
if b := told.IdentityBoundOf("thing"); b.Max != DefaultIdentityLimit {
|
|
t.Errorf("a provider told its consumers and silent about its backend is not held to %d: %+v",
|
|
DefaultIdentityLimit, b)
|
|
}
|
|
// Serving a value built from the identity is being told it, too.
|
|
serving := Manifest{Module: "serving", Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}},
|
|
Serves: map[string]map[string]any{"bucket": {"name": "b-${consumer:as:dns}"}}}
|
|
if b := serving.IdentityBoundOf("bucket"); b.Max != DefaultIdentityLimit {
|
|
t.Errorf("a provider deriving a name from its consumers is not bounded: %+v", b)
|
|
}
|
|
// And `false` says it outright, even for a provider that receives.
|
|
routes := Manifest{Module: "routes", Provides: []Offer{{Name: "route", Scope: ScopeMesh,
|
|
Identity: &OfferIdentity{None: true}}},
|
|
Receives: map[string]string{"route": "/var/lib/mesh/routes/mesh.json"}}
|
|
if b := routes.IdentityBoundOf("route"); b.Bounded() {
|
|
t.Errorf("`identity: false` still bounds: %+v", b)
|
|
}
|
|
}
|
|
|
|
// The field reads as written and writes back the same, and refuses what says nothing.
|
|
func TestAnOffersIdentityIsParsedStrictly(t *testing.T) {
|
|
for _, raw := range []string{
|
|
`{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`,
|
|
`{"name":"wildcard-resolution","scope":"mesh","identity":false}`,
|
|
`{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`,
|
|
} {
|
|
var o Offer
|
|
if err := json.Unmarshal([]byte(raw), &o); err != nil {
|
|
t.Fatalf("%s: %v", raw, err)
|
|
}
|
|
back, err := json.Marshal(o)
|
|
if err != nil || string(back) != raw {
|
|
t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err)
|
|
}
|
|
}
|
|
for _, raw := range []string{
|
|
`{"name":"x","identity":true}`,
|
|
`{"name":"x","identity":{"max":20,"in":"y","most":3}}`,
|
|
} {
|
|
var o Offer
|
|
if err := json.Unmarshal([]byte(raw), &o); err == nil {
|
|
t.Errorf("accepted %s", raw)
|
|
}
|
|
}
|
|
bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{
|
|
{Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}},
|
|
{Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}},
|
|
}}
|
|
raw, err := json.Marshal(bad)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = ParseManifest(raw)
|
|
if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") ||
|
|
!strings.Contains(err.Error(), "the shortest the mesh makes") {
|
|
t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// Refused before merge: the catalogue check judges each module's identity, on the longest machine
|
|
// name, against the bound of every provision it wants — and names the module and the slug to set.
|
|
func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) {
|
|
shelf := Shelf{
|
|
"objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
|
|
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
|
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}},
|
|
"photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}},
|
|
"files": {Module: "files", Requires: []string{"s3-bucket"}},
|
|
}
|
|
problems := IdentityProblems(shelf, 6)
|
|
if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") ||
|
|
!strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) ||
|
|
!strings.Contains(problems[0], "`slug` of at most 8 characters") {
|
|
t.Fatalf("one overflow, named with its remedy, was expected: %q", problems)
|
|
}
|
|
// A longer machine name refuses more: the check is about the mesh's machines, not one.
|
|
if got := IdentityProblems(shelf, 10); len(got) != 2 {
|
|
t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got)
|
|
}
|
|
// And a slug is the remedy it names.
|
|
album := shelf["photoalbum"]
|
|
album.Slug = "album"
|
|
shelf["photoalbum"] = album
|
|
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
|
t.Fatalf("a slug that fits is still refused: %q", got)
|
|
}
|
|
}
|
|
|
|
// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a
|
|
// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's
|
|
// whole machine with it; the resolver keeps no name, so it is not refused at all.
|
|
func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) {
|
|
shelf := Shelf{
|
|
"resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}},
|
|
"networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}},
|
|
}
|
|
if CheckIdentity("laptop", "networkmanager") == nil {
|
|
t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound")
|
|
}
|
|
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
|
t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got)
|
|
}
|
|
r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]},
|
|
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager",
|
|
Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}}
|
|
if got := r.Overflowing(); len(got) != 0 {
|
|
t.Fatalf("a keyless requirement is reported as overflowing: %+v", got)
|
|
}
|
|
}
|
|
|
|
// The consumer's side of the same judgement the provider's composition makes: what `status` says.
|
|
func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) {
|
|
bound := IdentityBound{Max: 20, In: "an S3 access key"}
|
|
r := Resolution{Node: "laptop",
|
|
Modules: []Manifest{{Module: "photoalbum"}, {Module: "files"}, {Module: "gallery", Slug: "gal"}},
|
|
Needs: []Needed{
|
|
{Name: "s3-bucket", From: "anchor", For: "photoalbum", Identity: bound},
|
|
{Name: "s3-bucket", From: "anchor", For: "photoalbum", Local: "second", Identity: bound},
|
|
{Name: "s3-bucket", From: "anchor", For: "files", Identity: bound},
|
|
{Name: "s3-bucket", From: "anchor", For: "gallery", Identity: bound},
|
|
{Name: "licence", From: "records", For: "photoalbum", ByRecord: true, Identity: bound},
|
|
}}
|
|
got := r.Overflowing()
|
|
if len(got) != 1 || got[0].Module != "photoalbum" || got[0].Identity != "mesh_laptop_photoalbum" ||
|
|
got[0].Provider != "anchor" {
|
|
t.Fatalf("one overflow, once, was expected: %+v", got)
|
|
}
|
|
if said := got[0].String(); !strings.Contains(said, "an S3 access key keeps 20") ||
|
|
!strings.Contains(said, "slug") {
|
|
t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said)
|
|
}
|
|
}
|
|
|
|
// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one.
|
|
func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) {
|
|
serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}}
|
|
wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh,
|
|
Identity: &OfferIdentity{Max: 255, In: "a client id"}}}}
|
|
if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") {
|
|
t.Fatalf("a 255-character bound on a DNS label passed: %q", got)
|
|
}
|
|
unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}}
|
|
if got := CheckServes(unsaid); len(got) != 0 {
|
|
t.Fatalf("the default bound (20) on a DNS label was refused: %q", got)
|
|
}
|
|
}
|