diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index affc57b8..6a389aca 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -2938,6 +2938,7 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M } } problems = append(problems, invokeProblems(m)...) + problems = append(problems, needsApprovalProblems(m)...) problems = append(problems, replacesProblems(m)...) problems = append(problems, UnitSettingProblems(m)...) problems = append(problems, OwnerSettingProblems(m)...) @@ -3389,6 +3390,103 @@ func replacesProblems(m catalogue.Manifest) []string { return problems } +// needsApprovalProblems refuses a mark the mesh cannot honour (design 46 *The approval mark* §4; design 34 every-event +// §32; ADR 0286 §4): a mark on a tool its tools do not list (where it lists them; a bundle's own tools are checked by +// the tool runner at load), on a verb its claims do not serve, or on an event neither its emits nor its streams' events +// carry; one name both one of its tools and one of its events; a tool or verb marked, or a held seat verb its seat +// marks, without operator-channel in uses (§7: a module marking only events needs none); and a tool or verb marked +// while every bundle it builds is run by one of its process resources, so its own program answers and no tool runner +// gates them. Each refusal names the entry and the rule. +func needsApprovalProblems(m catalogue.Manifest) []string { + var problems []string + tools, events, served := map[string]bool{}, map[string]bool{}, map[string]map[string]bool{} + for _, t := range m.Tools { + tools[t] = true + } + for _, e := range m.Emits { + events[e] = true + } + for _, st := range m.Streams { + for _, e := range st.Events { + events[e] = true + } + } + for _, c := range m.Claims { + served[c.Name] = map[string]bool{} + for _, v := range c.ServesFor(m) { + served[c.Name][v] = true + } + } + var acts []string // the tools and verbs it marks, and the held seat verbs its seats mark + for _, e := range m.NeedsApproval { + seat, verb, dotted := strings.Cut(e, ".") + switch { + case tools[e] && events[e]: + problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which names both one of its tools "+ + "and one of its events: an entry names one thing (design 46 approval mark §4)", m.Module, e)) + case events[e]: + case tools[e], dotted && served[seat][verb]: + acts = append(acts, e) + case dotted && served[seat] != nil: + problems = append(problems, fmt.Sprintf("%s marks the verb %q in needs-approval, which its claim of %s does "+ + "not serve (ADR 0286 §4)", m.Module, e, seat)) + case dotted: + problems = append(problems, fmt.Sprintf("%s marks %q in needs-approval, which is neither a verb its claims "+ + "serve nor an event its emits or its streams' events carry (ADR 0286 §4; design 34 every-event §32)", m.Module, e)) + case len(m.Tools) > 0: + problems = append(problems, fmt.Sprintf("%s marks the tool %q in needs-approval, which its tools do not list "+ + "(ADR 0286 §4)", m.Module, e)) + default: // a tool its bundle registers: the tool runner checks it at load (§4) + acts = append(acts, e) + } + } + marked := len(acts) > 0 + for _, c := range m.Claims { + seat, ok := catalogue.SeatNamed(c.Name) + if !ok { + continue + } + for _, v := range seat.Serves { + if v.NeedsApproval && served[c.Name][v.Name] { + acts = append(acts, c.Name+"."+v.Name) + } + } + } + if len(acts) > 0 && !slices.Contains(m.Uses, "operator-channel") { + problems = append(problems, fmt.Sprintf("%s marks or holds a marked tool or verb (%s) without operator-channel in "+ + "uses: its tool runner asks the operator through that seat (design 46 approval mark §4, §7)", + m.Module, strings.Join(acts, ", "))) + } + if marked && everyBundleRunByAProcess(m) { + problems = append(problems, fmt.Sprintf("%s marks a tool or verb while every bundle it builds is run by one of "+ + "its process resources: its own program answers them and no tool runner gates them, so the mark would guard "+ + "nothing (design 46 approval mark §4)", m.Module)) + } + return problems +} + +// everyBundleRunByAProcess says every artifact of kind bundle the module builds is the artifact of one of its process +// resources (none built is every one run so: no tool runner loads any). +func everyBundleRunByAProcess(m catalogue.Manifest) bool { + run := map[string]bool{} + for _, r := range m.Resources { + if r["type"] == "process" { + if a, ok := r["artifact"].(string); ok { + run[a] = true + } + } + } + if m.Build == nil { + return true + } + for _, a := range m.Build.Artifacts { + if a.Kind == "bundle" && !run[a.Name] { + return false + } + } + return true +} + // whileStoppedProblems judges one container's maintenance window against its own definition // (novox/hq ADR 0189). // diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index 15b0f1c6..214c8aea 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -3173,6 +3173,43 @@ func caseAManifestCarryingWaitsIsAFieldNotKnown(t *testing.T) { } } +// AM-D1 (design 46 approval mark §4, §21; design 34 every-event §32): a mark nothing enforces is refused, naming the +// entry and the rule; the login shell's and the forge's manifests of §21 are accepted, and a module marking only events +// needs no operator-channel. +func caseAMarkNothingEnforcesIsRefused(t *testing.T) { + const bundle = `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}` + const oc = `"uses":["operator-channel"]` + shell := `{"module":"zsh","claims":[{"name":"node-login-shell","scope":"node","serves":["execute"]}],"tools":["zsh_config"],` + bundle + for _, row := range []struct{ name, manifest, want string }{ + {"a tool its tools do not list", `{"module":"shop","tools":["sell"],"needs-approval":["refund"],` + oc + `,` + bundle + `}`, "its tools do not list"}, + {"a verb its claims do not serve", `{"module":"shop","claims":[{"name":"shop-till","serves":["price"]}],"tools":["price"],"needs-approval":["shop-till.refund"],` + oc + `,` + bundle + `}`, "does not serve"}, + {"a marked tool without operator-channel", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + bundle + `}`, "without operator-channel"}, + {"a held seat verb its seat marks without operator-channel", shell + `}`, "without operator-channel"}, + {"every bundle run by its own process", `{"module":"shop","tools":["sell"],"needs-approval":["sell"],` + oc + `,` + bundle + + `,"resources":[{"id":"serve","type":"process","artifact":"tools","user":"shop"}]}`, "every bundle it builds is run"}, + {"one name a tool and an event", `{"module":"shop","tools":["sold"],"emits":["sold"],"needs-approval":["sold"],` + oc + `,` + bundle + `}`, "both one of its tools"}, + {"an event it does not carry", `{"module":"shop","emits":["sold"],"needs-approval":["sold.again"]}`, "nor an event its emits"}, + {"the login shell", shell + `,` + oc + `}`, ""}, + {"the forge", `{"module":"forge","needs-approval":["gitea_branch_protection_loosen"],` + oc + `,"build":{"artifacts":[` + + `{"name":"code","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"],"loads":["tools/index.js"]},` + + `{"name":"repositories","kind":"bundle","language":"typescript","entrypoints":["repositories/index.js"]}]},` + + `"resources":[{"id":"repositories","type":"process","artifact":"repositories","user":"forge"}]}`, ""}, + {"only events, no operator-channel", `{"module":"shop","emits":["sold"],"streams":[{"name":"ledger","events":["booked"],"max-age-days":1,"max-mib":1}],"needs-approval":["sold","booked"]}`, ""}, + } { + var problems []string + WithSeats([]catalogue.Seat{{Name: "node-login-shell", Serves: []catalogue.Verb{{Name: "execute", NeedsApproval: true}}}}, func() { + _, problems = Manifest(versioned(t, []byte(row.manifest)), nil) + }) + got := strings.Join(problems, "\n") + switch { + case row.want == "" && got != "": + t.Errorf("%s is refused: %s", row.name, got) + case row.want != "" && !strings.Contains(got, row.want): + t.Errorf("%s: problems %q, want one saying %q", row.name, got, row.want) + } + } +} + // 1178/AC2: a work-queue stream is read by exactly one process of its module and by no other module's consumes: one // process naming it is accepted; none, two, or another module's consumes is refused, naming it. func caseAWorkQueueStreamIsReadByExactlyOneProcessOfItsModule(t *testing.T) { @@ -3974,7 +4011,7 @@ func caseNoModuleMayDeclareTheLoginShell(t *testing.T) { t.Fatalf("declaring login-shell was not refused by name: %q", got) } // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. - m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + + m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],"uses":["operator-channel"],` + `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) if err != nil { t.Fatal(err) @@ -6382,6 +6419,7 @@ func TestRulesCatalogue(t *testing.T) { // TestRulesManifests runs the tests of one kind as subtests, each by its former name. func TestRulesManifests(t *testing.T) { t.Run("AMalformedEnvironmentIsRefusedAtParse", caseAMalformedEnvironmentIsRefusedAtParse) + t.Run("AMarkNothingEnforcesIsRefused", caseAMarkNothingEnforcesIsRefused) t.Run("AMalformedFamilyIsRefused", caseAMalformedFamilyIsRefused) t.Run("AMalformedPromptIsRefused", caseAMalformedPromptIsRefused) t.Run("AManifestIsJudgedAgainstTheListOfItsTree", caseAManifestIsJudgedAgainstTheListOfItsTree) diff --git a/modules/mesh-delivery-indexer/testdata/beside/CAPTURED b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED index 6db0cff8..f4430ea3 100644 --- a/modules/mesh-delivery-indexer/testdata/beside/CAPTURED +++ b/modules/mesh-delivery-indexer/testdata/beside/CAPTURED @@ -13,6 +13,8 @@ was mesh-controller's copy, which still held two modules that had left the catal mesh-catalog b5dbfbb784507e211372a7c9640d91f7d6dadd84 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json mesh-host 233bb23bbcb1a829ce2a6164a31a5c5cc4f3d950 internal/apply/placement_guard.go +mesh-catalog 2d5358b8 (milestone 47/G's AM-K1) modules/zsh/module.json alone: the login shell uses operator-channel for + its seat's marked execute (design 46 approval mark §21), which the validator requires (§4) To move them, from mesh-delivery's modules/mesh-delivery-indexer directory, with the two repositories checked out beside mesh-delivery: diff --git a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured index da125af2..028f2213 100644 --- a/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured +++ b/modules/mesh-delivery-indexer/testdata/beside/mesh-catalog/modules/zsh/module.json.captured @@ -1,6 +1,6 @@ { "module": "zsh", - "version": "1", + "version": "0.1", "moved": true, "capabilities": [ "package-manager" @@ -14,6 +14,9 @@ ] } ], + "uses": [ + "operator-channel" + ], "tools": [ "zsh_config" ],