diff --git a/modules/mesh-delivery-assigner/go.mod b/modules/mesh-delivery-assigner/go.mod index 1408845..7beb07e 100644 --- a/modules/mesh-delivery-assigner/go.mod +++ b/modules/mesh-delivery-assigner/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-assigner go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-assigner/go.sum b/modules/mesh-delivery-assigner/go.sum index 2a3e9f3..e524e7f 100644 --- a/modules/mesh-delivery-assigner/go.sum +++ b/modules/mesh-delivery-assigner/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468 h1:UcMee+IV2ii5E4dRZdQUaSaeh9IedTFYumuQbBiB0k0= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023150321-a2c813a6b468/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce h1:9pNnygyhuYIpVs0HPzgheGLNjPXigGGQpnFTp8lw+X4= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024015135-4784762a70ce/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-delivery-assigner/internal/assign/compose.go b/modules/mesh-delivery-assigner/internal/assign/compose.go index d609c87..b1f26e4 100644 --- a/modules/mesh-delivery-assigner/internal/assign/compose.go +++ b/modules/mesh-delivery-assigner/internal/assign/compose.go @@ -32,6 +32,9 @@ type Mesh struct { Manifests map[string]catalogue.Manifest Unreadable map[string]string Assigned map[string][]string + // Digests is every trunk module's manifest digest in the indexer's manifests bucket, readable or not: what a + // composer at another mesh-sdk reads (issue 1622). + Digests map[string]string } // Change is a group's change as the indexer's candidate holds it: the manifest of every module it moves or adds, those @@ -45,6 +48,10 @@ type Change struct { Twice map[string][]string SDKHead string Rebuilt int + // Digests is the manifest digest of every module the change moves or adds, readable by the assigner or not, and + // Members the head of each member by repository: what a composer at the group's mesh-sdk reads (issues 1622, 1623). + Digests map[string]string + Members map[string]string } // Touched is the machines a change touches (ADR 0446 clause 3): those running a module the change moves, adds or @@ -167,13 +174,19 @@ func Compose(m Mesh, c Change, sdk string, took time.Duration) delivery.Composit failAt(node, fmt.Sprintf("%s composes on the trunk and not with the change: %v", node, now)) } } - if c.Rebuilt > delivery.CompositionRebuildWarning { - v.Warnings = append(v.Warnings, fmt.Sprintf("the merge rebuilds %d modules, more than %d", c.Rebuilt, delivery.CompositionRebuildWarning)) - } if c.SDKHead != "" { v.Warnings = append(v.Warnings, fmt.Sprintf("composed with the assigner's own mesh-sdk %s, not the group's mesh-sdk head %s, "+ "and a field the running controller cannot read is not judged (issues 1621, 1622, 1623)", sdk, c.SDKHead)) } + return finish(v, m, c, machines, fails) +} + +// finish adds what every composition says beside its own judgement (more modules rebuilt than CompositionRebuildWarning, +// what is not judged, the machines not composed, a change that touches none) and its state from fails. +func finish(v delivery.CompositionVerdict, m Mesh, c Change, machines []string, fails []string) delivery.CompositionVerdict { + if c.Rebuilt > delivery.CompositionRebuildWarning { + v.Warnings = append(v.Warnings, fmt.Sprintf("the merge rebuilds %d modules, more than %d", c.Rebuilt, delivery.CompositionRebuildWarning)) + } v.Warnings = append(v.Warnings, NotJudged) if could := CouldRun(m, c, machines); len(could) > 0 { v.Warnings = append(v.Warnings, "not judged until the facts snapshot is on the bus (issue 1461, ADR 0537 clause 1): "+ diff --git a/modules/mesh-delivery-assigner/internal/assign/composer.go b/modules/mesh-delivery-assigner/internal/assign/composer.go new file mode 100644 index 0000000..b8ccb02 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/composer.go @@ -0,0 +1,241 @@ +package assign + +import ( + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/compose" + "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The cases of a group's composition at its mesh-sdk head: the trunk as it is, and the trunk with the change. +const ( + CaseTrunk = "trunk" + CaseChange = "change" +) + +// SDKDir is the directory of mesh-sdk's Go module in its repository, where the composer is built at a head; a reader's +// composer is built at its repository's root, at the mesh-sdk its go.mod pins. +const SDKDir = "go" + +// The modules whose mesh-sdk reads the manifests a plan sends (ADR 0482 clause 5): the controller composes and sends +// them, the node-engine applies them. Each is judged at its repository. +var ReaderModules = map[string]string{"mesh-controller": "novox/mesh-controller", "mesh-host": "novox/mesh-host"} + +// HeadJob is the compose job of a group with a mesh-sdk member (ADR 0482 clauses 3 and 4, issue 1622): the composer +// built at the member's head composes the machines the change touches, as the trunk holds them and as the change leaves +// them, every manifest named by its digest in the indexer's bucket, the seats held from the claims (the assigner's input). +func HeadJob(m Mesh, c Change) (delivery.ComposeJob, error) { + trunk, change := map[string]composer.Manifest{}, map[string]composer.Manifest{} + for name, digest := range m.Digests { + trunk[name] = composer.Manifest{Digest: digest} + change[name] = composer.Manifest{Digest: digest} + } + for name, digest := range c.Digests { + change[name] = composer.Manifest{Digest: digest} + } + for _, name := range c.Removed { + delete(change, name) + } + req := composer.Request{V: composer.Version, Input: plainInput(m.Assigned), Depth: composer.DepthResolve, + HoldingsFromClaims: true, Machines: Touched(m, c), + Cases: []composer.Case{{Name: CaseTrunk, Manifests: trunk}, {Name: CaseChange, Manifests: change}}} + return job(SDKRepository, c.SDKHead, SDKDir, req, "the mesh-sdk head of "+c.Group) +} + +// ReaderJob is the compose job that reads the manifests a group changes with the mesh-sdk a reader module pins at +// commit (ADR 0482 clause 5, issue 1623): no machine is composed, every changed manifest is only read. +func ReaderJob(c Change, module, repository, commit string) (delivery.ComposeJob, error) { + changed := map[string]composer.Manifest{} + for name, digest := range c.Digests { + changed[name] = composer.Manifest{Digest: digest} + } + req := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: CaseChange, Manifests: changed}}} + return job(repository, commit, delivery.RootPath, req, module+" at "+commit) +} + +func job(repository, commit, dir string, req composer.Request, reader string) (delivery.ComposeJob, error) { + raw, err := json.Marshal(req) + if err != nil { + return delivery.ComposeJob{}, err + } + return delivery.ComposeJob{Fetch: map[string]any{"repository": repository, "commit": commit}, Dir: dir, + Package: composer.Package, Request: raw, Reader: reader}, nil +} + +// plainInput is the composition's input without its catalogue: the nodes by name with what is assigned to them (the +// composer derives the seats held from the claims, as input does). +func plainInput(assigned map[string][]string) compose.Input { + var in compose.Input + nodes := make([]string, 0, len(assigned)) + for n := range assigned { + nodes = append(nodes, n) + } + sort.Strings(nodes) + for _, n := range nodes { + in.Nodes = append(in.Nodes, compose.Node{Name: n, Assigned: slices.Clone(assigned[n])}) + } + return in +} + +// Ran is one compose ask's end: its step's state and reason, and the composer's verdict when the step passed. +type Ran struct { + State string + Reason string + Verdict *composer.Verdict +} + +// RanOf reads a compose ask's end from its step value; a step that passed with an answer that cannot be read is a +// failure, named. +func RanOf(step delivery.Step) Ran { + r := Ran{State: step.State, Reason: step.Reason} + if step.State != delivery.StepPassed { + return r + } + var a delivery.ComposeAnswer + var v composer.Verdict + if err := json.Unmarshal(step.Result, &a); err != nil { + return Ran{State: delivery.StepFailed, Reason: "the compose step's answer cannot be read: " + err.Error()} + } + if err := json.Unmarshal(a.Verdict, &v); err != nil || v.V != composer.Version { + return Ran{State: delivery.StepFailed, Reason: fmt.Sprintf("the composer's verdict cannot be read (version %d): %v", v.V, err)} + } + r.Verdict = &v + return r +} + +func caseOf(v *composer.Verdict, name string) (composer.CaseVerdict, bool) { + if v == nil { + return composer.CaseVerdict{}, false + } + for _, c := range v.Cases { + if c.Name == name { + return c, true + } + } + return composer.CaseVerdict{}, false +} + +// Reader is one module whose mesh-sdk reads the group's manifests, at the commit judged: the group's member head when +// the group holds one in its repository, else a build its node runs. +type Reader struct { + Module, Commit string + Member bool + Ran Ran +} + +// FromComposer is the composition check of a group with a mesh-sdk member, judged from the composer built at its head +// (ADR 0482 clauses 3 to 5, issues 1622 and 1623): the same failures as Compose, read from the composer's two cases, the +// verdict naming the mesh-sdk the composer was built with; a trunk manifest the head's mesh-sdk refuses fails too (the +// group would deliver a reader that refuses it); and each reader that refuses a changed manifest the head reads fails +// it, naming the module and its words, unless the reader's mesh-sdk has no composer to ask (before issue 1621), which is +// said and judged nothing. +func FromComposer(m Mesh, c Change, head Ran, readers []Reader, took time.Duration) delivery.CompositionVerdict { + v := delivery.CompositionVerdict{Group: c.Group, Heads: c.Heads, SDK: "mesh-sdk " + c.SDKHead, Took: took.Milliseconds()} + machines := Touched(m, c) + v.Machines = machines + var fails []string + if head.State != delivery.StepPassed { + v.Words = fmt.Sprintf("the composer at mesh-sdk %s did not run: %s %s", c.SDKHead, head.State, head.Reason) + v.State = delivery.CompositionFail + return v + } + if head.Verdict.SDK != "" { + v.SDK = head.Verdict.SDK + } + trunk, okT := caseOf(head.Verdict, CaseTrunk) + change, okC := caseOf(head.Verdict, CaseChange) + if !okT || !okC { + v.State, v.Words = delivery.CompositionFail, "the composer's verdict holds no trunk or no change case" + return v + } + failAt := func(machine, words string) { + if v.Machine == "" && machine != "" { + v.Machine = machine + } + fails = append(fails, words) + } + for _, name := range sortedKeys(c.Digests) { + if r := change.Manifests[name]; !r.Accepted { + failAt("", fmt.Sprintf("the manifest of %s cannot be read by mesh-sdk %s: %s", name, c.SDKHead, r.Refused)) + } + } + for _, name := range sortedKeys(trunk.Manifests) { + if _, changed := c.Digests[name]; changed || slices.Contains(c.Removed, name) || trunk.Manifests[name].Accepted { + continue + } + if _, before := m.Unreadable[name]; before { + continue // already unreadable on the trunk: not the group's doing + } + failAt("", fmt.Sprintf("mesh-sdk %s refuses the trunk's manifest of %s, which the group does not change: %s", c.SDKHead, name, trunk.Manifests[name].Refused)) + } + for _, name := range sortedKeys(c.Twice) { + failAt("", fmt.Sprintf("%s is defined twice, in %s", name, strings.Join(c.Twice[name], " and "))) + } + for _, node := range machines { + for _, name := range c.Removed { + if slices.Contains(m.Assigned[node], name) { + failAt(node, fmt.Sprintf("%s runs %s, which the change removes from its source", node, name)) + } + } + before, now := trunk.Machines[node], change.Machines[node] + switch { + case now.Composes: + case !before.Composes: + v.Warnings = append(v.Warnings, fmt.Sprintf("%s does not compose on the trunk either, so the change is not blamed: %s", node, before.Reason)) + default: + failAt(node, fmt.Sprintf("%s composes on the trunk and not with the change: %s", node, now.Reason)) + } + } + readerFails, readerWarnings := judgeReaders(c, change, readers) + fails = append(fails, readerFails...) + v.Warnings = append(v.Warnings, readerWarnings...) + return finish(v, m, c, machines, fails) +} + +// NoComposer says whether a compose step failed because the mesh-sdk it was built at has no composer command (a mesh-sdk +// from before issue 1621): Go's words for a package its module does not hold. +func NoComposer(reason string) bool { + for _, words := range []string{"cannot find package", "no required module provides package", "is not in std", "cannot find module providing package", "does not contain package"} { + if strings.Contains(reason, words) { + return true + } + } + return false +} + +func judgeReaders(c Change, head composer.CaseVerdict, readers []Reader) (fails, warnings []string) { + for _, r := range readers { + at := r.Module + " at " + r.Commit + if r.Member { + at = r.Module + "'s member head " + r.Commit + } + if r.Ran.State != delivery.StepPassed { + if NoComposer(r.Ran.Reason) { + warnings = append(warnings, fmt.Sprintf("not judged whether %s reads the group's manifests: the mesh-sdk it pins has no "+ + "composer command (before issue 1621)", at)) + continue + } + fails = append(fails, fmt.Sprintf("the composer of the mesh-sdk %s pins did not run: %s %s", at, r.Ran.State, r.Ran.Reason)) + continue + } + read, ok := caseOf(r.Ran.Verdict, CaseChange) + if !ok { + fails = append(fails, fmt.Sprintf("the composer of the mesh-sdk %s pins answered no change case", at)) + continue + } + for _, name := range sortedKeys(c.Digests) { + if !head.Manifests[name].Accepted || read.Manifests[name].Accepted { + continue + } + fails = append(fails, fmt.Sprintf("the manifest of %s uses what %s (mesh-sdk %s) cannot read: %s; the group must move %s onto "+ + "mesh-sdk %s", name, at, r.Ran.Verdict.SDK, read.Manifests[name].Refused, r.Module, c.SDKHead)) + } + } + return fails, warnings +} diff --git a/modules/mesh-delivery-assigner/internal/assign/composer_test.go b/modules/mesh-delivery-assigner/internal/assign/composer_test.go new file mode 100644 index 0000000..4b709c8 --- /dev/null +++ b/modules/mesh-delivery-assigner/internal/assign/composer_test.go @@ -0,0 +1,184 @@ +package assign + +import ( + "encoding/json" + "slices" + "strings" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/composer" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +var testTime = time.Date(2026, 10, 24, 3, 0, 0, 0, time.UTC) + +const sdkHead = "1111111111111111111111111111111111111111" + +// raws are the manifests of meshOf as their repositories hold them, by digest, as the indexer keeps them; `web2` is web +// with a field `shares` that the assigner's own mesh-sdk does not read (the shape of issue 1593). +var raws = map[string]string{ + "d-mail": `{"module": "mail", "version": "1", "requires": ["postgres-database"]}`, + "d-store": `{"module": "store", "version": "1", "provides": [{"name": "postgres-database"}]}`, + "d-web": `{"module": "web", "version": "1"}`, + "d-web2": `{"module": "web", "version": "2", "shares": ["photos"]}`, +} + +// asTheHeadReads is how mesh-sdk at the group's head reads each manifest: as the assigner's own reads it, but for the +// field it adds, which it accepts. The head's composer is built and run by the Go build module (ADR 0482 clause 4); here +// its verdict is made by mesh-sdk's composer.Run over the same request, with `web2` read as the head reads it. +var asTheHeadReads = map[string]string{"d-web2": `{"module": "web", "version": "2"}`} + +func digestedMesh() Mesh { + m := meshOf() + m.Digests = map[string]string{"mail": "d-mail", "store": "d-store", "web": "d-web"} + return m +} + +// webShares is a group of mesh-sdk and mesh-catalog whose catalogue member gives web the field only the SDK head reads: +// the assigner's own reader refuses it (Unreadable), as the indexer's manifest is read in world.change. +func webShares(members map[string]string) Change { + _, err := catalogue.ParseManifest([]byte(raws["d-web2"])) + return Change{Group: "g", Heads: "0123456789ab", SDKHead: sdkHead, Manifests: map[string]catalogue.Manifest{}, + Unreadable: map[string]string{"web": err.Error()}, Digests: map[string]string{"web": "d-web2"}, Members: members} +} + +// run is the composer's step for a job, as the build module answers it: the request filled from the indexer's digests +// (with read standing in for how that mesh-sdk reads a digest), run, and its verdict put in a passed step. +func run(t *testing.T, job delivery.ComposeJob, read map[string]string) Ran { + t.Helper() + req, err := composer.Decode(job.Request) + if err != nil { + t.Fatal(err) + } + if err := composer.Fill(&req, func(d string) ([]byte, error) { + if r, ok := read[d]; ok { + return []byte(r), nil + } + return []byte(raws[d]), nil + }); err != nil { + t.Fatal(err) + } + v, err := composer.Run(req) + if err != nil { + t.Fatal(err) + } + v.SDK = "(devel) " + job.Fetch["commit"].(string) + vr, _ := json.Marshal(v) + ans, _ := json.Marshal(delivery.ComposeAnswer{Built: true, Key: "composer-0", Verdict: vr}) + return RanOf(delivery.Step{State: delivery.StepPassed, Result: ans}) +} + +// 1622/AC1: a group holding a mesh-sdk pull request is composed by the composer built at the member's head: the job +// fetches mesh-sdk at that head, builds mesh-sdk's composer command in its Go module, and hands it the trunk and the +// change by digest, never the manifests' bytes, composing the machines the change touches. +func TestAGroupWithAnSDKMemberAsksTheComposerAtItsHead(t *testing.T) { + c := webShares(nil) + job, err := HeadJob(digestedMesh(), c) + if err != nil { + t.Fatal(err) + } + if job.Fetch["repository"] != SDKRepository || job.Fetch["commit"] != sdkHead || job.Dir != "go" || job.Package != composer.Package { + t.Fatalf("job = %+v", job) + } + ask := delivery.StepAsk{ID: "mh3k2x9qa1b2c5", Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "mesh-delivery-assigner", + At: testTime, Compose: &job} + if _, err := delivery.EncodeStepAsk(ask); err != nil { + t.Fatalf("the ask is refused by its layout: %v", err) + } + req, err := composer.Decode(job.Request) + if err != nil { + t.Fatal(err) + } + if len(req.Cases) != 2 || req.Cases[0].Name != CaseTrunk || req.Cases[1].Name != CaseChange { + t.Fatalf("cases = %+v", req.Cases) + } + if req.Cases[0].Manifests["web"].Digest != "d-web" || req.Cases[1].Manifests["web"].Digest != "d-web2" || len(req.Cases[1].Manifests["web"].Raw) != 0 { + t.Errorf("the cases do not name web by its trunk and its change digest: %+v", req.Cases) + } + if !slices.Equal(req.Machines, []string{"g14"}) || req.Depth != composer.DepthResolve || !req.HoldingsFromClaims { + t.Errorf("request = machines %v, depth %q, holdings from claims %v", req.Machines, req.Depth, req.HoldingsFromClaims) + } +} + +// 1622/AC2, AC3: a catalogue member using a field only the SDK head reads passes with the head's composer, and the +// verdict names the mesh-sdk the composer was built with; with the assigner's own mesh-sdk it fails. +func TestAFieldTheGroupAddsPassesWithTheHeadsComposer(t *testing.T) { + c := webShares(map[string]string{"novox/mesh-controller": "2222222222222222222222222222222222222222"}) + job, _ := HeadJob(digestedMesh(), c) + head := run(t, job, asTheHeadReads) + v := FromComposer(digestedMesh(), c, head, nil, 0) + if v.State != delivery.CompositionPass || v.SDK != "(devel) "+sdkHead || !slices.Equal(v.Machines, []string{"g14"}) { + t.Fatalf("with the head's composer: %+v", v) + } + if _, err := delivery.EncodeCompositionVerdict(v); err != nil { + t.Errorf("the verdict cannot be written: %v", err) + } + own := Compose(digestedMesh(), c, "v0.1.14-own", 0) + if own.State != delivery.CompositionFail || !strings.Contains(own.Words, "shares") { + t.Fatalf("with the assigner's own mesh-sdk: %+v, want a fail naming the field", own) + } +} + +// 1623/AC1, AC2: the group of mesh-sdk and mesh-catalog adding and using a field, without a controller member, fails +// naming the field and mesh-controller, whose running build pins an older mesh-sdk; with a controller member pinned to +// the SDK head (its composer reads what the head reads) it passes. +func TestAFieldTheRunningControllerCannotReadFailsTheGroup(t *testing.T) { + c := webShares(nil) + job, _ := HeadJob(digestedMesh(), c) + head := run(t, job, asTheHeadReads) + running := "3333333333333333333333333333333333333333" + rjob, err := ReaderJob(c, "mesh-controller", "novox/mesh-controller", running) + if err != nil { + t.Fatal(err) + } + if rjob.Fetch["commit"] != running || rjob.Dir != delivery.RootPath { + t.Fatalf("reader job = %+v", rjob) + } + old := run(t, rjob, nil) // the running controller's mesh-sdk reads web2 as the assigner's own does: refused + v := FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-controller", Commit: running, Ran: old}}, 0) + if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "shares") || !strings.Contains(v.Words, "move mesh-controller onto mesh-sdk "+sdkHead) { + t.Fatalf("without a controller member: %+v, want a fail naming the field and mesh-controller", v) + } + member := "2222222222222222222222222222222222222222" + pinned := run(t, rjob, asTheHeadReads) + v = FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-controller", Commit: member, Member: true, Ran: pinned}}, 0) + if v.State != delivery.CompositionPass { + t.Fatalf("with a controller member pinned to the head: %+v, want a pass", v) + } +} + +// 1623: a reader whose mesh-sdk has no composer command (before issue 1621) is said, not failed; a reader's composer that +// failed for another cause fails the group, loud. +func TestAReaderWithNoComposerIsSaidAndAnotherFailureFails(t *testing.T) { + c := webShares(nil) + job, _ := HeadJob(digestedMesh(), c) + head := run(t, job, asTheHeadReads) + none := Ran{State: delivery.StepFailed, Reason: "go build: no required module provides package git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose"} + v := FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-host", Commit: "abc", Ran: none}}, 0) + if v.State != delivery.CompositionPass || !hasWarning(v, "mesh-host at abc") || !hasWarning(v, "before issue 1621") { + t.Fatalf("a reader with no composer: %+v", v) + } + broke := Ran{State: delivery.StepFailed, Reason: "the fetch could not reach the forge"} + v = FromComposer(digestedMesh(), c, head, []Reader{{Module: "mesh-host", Commit: "abc", Ran: broke}}, 0) + if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "could not reach the forge") { + t.Fatalf("a reader whose composer failed: %+v", v) + } +} + +// 1622: the head's composer that did not run fails the composition naming why, never passes it; a trunk manifest the +// head's mesh-sdk refuses fails it too, since the group would deliver a reader that refuses it. +func TestTheHeadsComposerFailingOrRefusingTheTrunkFails(t *testing.T) { + c := webShares(nil) + v := FromComposer(digestedMesh(), c, Ran{State: delivery.StepRefused, Reason: "the guard refuses it"}, nil, 0) + if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "the guard refuses it") || !strings.Contains(v.Words, sdkHead) { + t.Fatalf("a head composer that did not run: %+v", v) + } + job, _ := HeadJob(digestedMesh(), c) + strict := map[string]string{"d-web2": asTheHeadReads["d-web2"], "d-mail": `{"module": "mail", "version": "1", "gone": true}`} + v = FromComposer(digestedMesh(), c, run(t, job, strict), nil, 0) + if v.State != delivery.CompositionFail || !strings.Contains(v.Words, "refuses the trunk's manifest of mail") { + t.Fatalf("a head that refuses a trunk manifest: %+v", v) + } +}