diff --git a/node-tools/cmd/node-tools/main.go b/node-tools/cmd/node-tools/main.go index 7da745a..bd861b1 100644 --- a/node-tools/cmd/node-tools/main.go +++ b/node-tools/cmd/node-tools/main.go @@ -26,6 +26,7 @@ import ( "time" "github.com/novox/mesh-tools/node-tools/internal/alive" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" "github.com/novox/mesh-tools/node-tools/internal/console" "github.com/novox/mesh-tools/node-tools/internal/firstview" @@ -51,7 +52,7 @@ func main() { cred := credential(until) conn := connectPatiently(cred, until) // And the seat list it describes the seats by, read through the seat mesh-catalog (novox/hq ADR 0636 §4). - _ = startSeatList(conn) + seatList := startSeatList(conn) served, err := runtime.ServedModulesFrom(os.Getenv(runtime.ToolModules), cred.Module) if err != nil { @@ -69,6 +70,14 @@ func main() { defer ledgers.Stop() opts = append(opts, runtime.WithLedgerWriters(ledgers.Writer)) } + // The indexer's and the assigner's states the console indexes the mesh by, and the gate reads its marks from, come to it as + // first views, asked of the owners' own tools and followed on their views families, never read from their state (novox/hq + // ADR 0615 §1; design 46 7-approval-mark §5). + source, stopViews := firstview.Start(context.Background(), conn, log.Printf) + defer stopViews() + gate := approval.New(approval.Deps{Node: conn.Node(), Runner: cred.Module, Catalog: source.ApprovalCatalog(definitionsOf(seatList)), + Findings: source.Findings(), Logf: log.Printf}) + opts = append(opts, runtime.WithGate(gate)) stop, err := runtime.Run(conn, served, envs, log.Printf, opts...) if err != nil { log.Fatalf("node-tools: %v", err) @@ -90,10 +99,6 @@ func main() { } var up *console.Listening if listen != "" { - // The indexer's and the assigner's states the console indexes the mesh by come to it as first views, asked of the owners' - // own tools and followed on their views families, never read from their state (novox/hq ADR 0615 §1). - source, stopViews := firstview.Start(context.Background(), conn, log.Printf) - defer stopViews() node := cred.Node if node == "" { node = "?" diff --git a/node-tools/cmd/node-tools/seatlist.go b/node-tools/cmd/node-tools/seatlist.go index 5d93ec3..a545710 100644 --- a/node-tools/cmd/node-tools/seatlist.go +++ b/node-tools/cmd/node-tools/seatlist.go @@ -71,3 +71,14 @@ func startSeatListOn(b seatlist.Bus, bound <-chan time.Time, logf func(string, . } return reader } + +// definitionsOf is the seat definitions the reader holds, by seat, which carry the verbs' marks (design 46 7-approval-mark +// §2, §5); none while the reader did not start. +func definitionsOf(reader *seatlist.Reader) func() map[string]seatdef.Definition { + return func() map[string]seatdef.Definition { + if reader == nil { + return nil + } + return reader.Definitions() + } +} diff --git a/node-tools/internal/approval/gate.go b/node-tools/internal/approval/gate.go new file mode 100644 index 0000000..02f7d07 --- /dev/null +++ b/node-tools/internal/approval/gate.go @@ -0,0 +1,135 @@ +package approval + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The runner's findings of the gate (design 46 7-approval-mark §4, §16, §19), written through its held writer. +const ( + KindMarkUnserved = "mark-unserved" + KindCallUnanswered = "call-unanswered" + KindApprovalsUnreadable = "approvals-unreadable" +) + +// Kinds are the finding kinds the gate writes through the runner's writer. +var Kinds = []string{KindMarkUnserved, KindCallUnanswered, KindApprovalsUnreadable} + +// FindingWriter is the runner's held finding writer (views.HeldWriter over the contract's Finding). +type FindingWriter interface { + Write(ctx context.Context, finding condseat.Finding) error + Clear(ctx context.Context, scope, id, kind, why string) error +} + +// Deps is what the gate reaches the mesh through: the runner's connection in its one place, fakes in a test. +type Deps struct { + // Node is the machine the runner serves; Runner its own module's name, the subject of its findings. + Node, Runner string + // Catalog is the catalog half the marks are read from. + Catalog Catalog + // Findings is the runner's held writer; nil says a finding in the log only. + Findings FindingWriter + Logf func(string, ...any) + Now func() time.Time +} + +// Gate is the runner's approval gate: every call of a module it serves goes through it before the bundle (§20). +type Gate struct { + d Deps + mu sync.Mutex + // registered is each module's tools as its bundle registered them at its last load. + registered map[string]map[string]bool + // unserved is each module whose marks name a tool its bundle does not register, with those tools. + unserved map[string][]string +} + +// New is a gate over d. +func New(d Deps) *Gate { + if d.Logf == nil { + d.Logf = func(string, ...any) {} + } + if d.Now == nil { + d.Now = time.Now + } + return &Gate{d: d, registered: map[string]map[string]bool{}, unserved: map[string][]string{}} +} + +// Unserved is, of the module's marked tools, those its bundle does not register (§4): such a bundle is not loaded, its +// finding `mark-unserved` raised; a bundle that serves them, or marks that no longer name them, clear it. Before the marks +// are read nothing is known, and every marked call is refused until they are (§19). +func (g *Gate) Unserved(module, fingerprint string, registered []string) []string { + set := map[string]bool{} + for _, t := range registered { + set[t] = true + } + g.mu.Lock() + g.registered[module] = set + g.mu.Unlock() + if g.d.Catalog == nil || g.d.Catalog.Ready() != nil { + return nil + } + return g.checkServed(Derive(g.d.Catalog, module, fingerprint)) +} + +// checkServed raises or clears the module's `mark-unserved` as its marks and its bundle's registrations stand now, and is +// the tools it marks and does not register. +func (g *Gate) checkServed(m Marks) []string { + g.mu.Lock() + set := g.registered[m.Module] + var missing []string + for t := range m.Tools { + if !set[t] { + missing = append(missing, t) + } + } + sort.Strings(missing) + was := g.unserved[m.Module] + if len(missing) > 0 { + g.unserved[m.Module] = missing + } else { + delete(g.unserved, m.Module) + } + g.mu.Unlock() + switch { + case len(missing) > 0 && strings.Join(was, ",") != strings.Join(missing, ","): + g.finding(m.Module, KindMarkUnserved, true, fmt.Sprintf("%s's marks (%s, catalog revision %s) name %s, which its bundle on %s does not "+ + "register: no tool of %s is served here", m.Module, sourceOf(m, missing[0]), m.Revision, strings.Join(missing, ", "), g.d.Node, m.Module), + fmt.Sprintf("%s is not served on %s", m.Module, g.d.Node)) + case len(missing) == 0 && len(was) > 0: + g.finding(m.Module, KindMarkUnserved, false, "a bundle that serves every marked tool, or marks without the tool, is loaded", "") + } + return missing +} + +func sourceOf(m Marks, tool string) string { return m.Tools[tool] } + +// finding writes or clears one of the gate's findings: scope machine, its id `.` escaped as a key's name is, +// about the runner's module on this machine (lead answer A-154). +func (g *Gate) finding(what, kind string, raised bool, summary, headline string) { + id := keys.EscapeName(g.d.Node + "." + what) + if g.d.Findings == nil { + g.d.Logf("[mesh-tools] %s %s: %s", kind, id, summary) + return + } + var err error + if raised { + err = g.d.Findings.Write(context.Background(), condseat.Finding{Kind: kind, Scope: scopeMachine, ID: id, + Severity: condseat.FindingSeverityWarning, Source: "approval", Observer: g.d.Runner, + Subject: condseat.Subject{Scope: "module", ID: g.d.Runner, Machine: g.d.Node}, Summary: summary, Headline: headline, + At: g.d.Now().UTC()}) + } else { + err = g.d.Findings.Clear(context.Background(), scopeMachine, id, kind, summary) + } + if err != nil { + g.d.Logf("[mesh-tools] the finding %s of %s is not kept: %v", kind, id, err) + } +} + +const scopeMachine = "machine" diff --git a/node-tools/internal/approval/marks.go b/node-tools/internal/approval/marks.go new file mode 100644 index 0000000..a61d59a --- /dev/null +++ b/node-tools/internal/approval/marks.go @@ -0,0 +1,176 @@ +// Package approval is the tool runner's gate on a call that needs the operator's approval (design 46 7-approval-mark): the +// marks it reads from the catalog half, the act it digests, the ask it raises on the seat operator-channel under the +// marked module's name, the judging of the same call again on the router's record, the claim, the recording in the +// hand-act log, and the refusals when a part of the check is missing (§5, §8 to §20). No module's name is written here. +package approval + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// Catalog is what the marks are read from: the catalog half the runner follows (design 46 7-approval-mark §5, design 47 G +// §196, §443) and the seat definitions it carries (§2). +type Catalog interface { + // Ready is nil once the catalog half's first view is read, else says what it waits for. + Ready() error + // Revision is the catalog revision the marks are read at, as the runner names it. + Revision() string + // Served is the entry of the build a bundle serves, by the fingerprint its composed environment carries; false when + // none is read (not yet, or not found), with why. + Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) + // Newest is the module's entry at the newest revision; false when the catalog half lists none. + Newest(module string) (catalogseat.CatalogEntry, bool) + // Definitions are the seat definitions the catalog half carries, by seat. + Definitions() map[string]seatdef.Definition +} + +// Marks is what one module's calls are gated by: its marked tools and seat verbs, and the subjects of its marked events and +// restricted streams, each with its source (§18: its module's entry in the catalog half, or its seat's definition). +type Marks struct { + Module string + // Tools are its marked tools by name, Verbs its marked seat verbs as `.`, Subjects the bus subjects of its + // marked events and restricted streams (design 34 every-event §33); each to its source. + Tools, Verbs, Subjects map[string]string + // Revision is the catalog revision read; Served the served entry's fingerprint, empty when it could not be read. + Revision, Served string + // Unread says why the served entry could not be read: every marked call is then refused (§5, §19). + Unread string +} + +// Marked says whether a tool or a seat verb of the module needs the operator's approval, and its source. +func (m Marks) Marked(seat, name string) (bool, string) { + if seat == "" { + src, ok := m.Tools[name] + return ok, src + } + src, ok := m.Verbs[seat+"."+name] + return ok, src +} + +// Any says the module marks anything at all. +func (m Marks) Any() bool { return len(m.Tools)+len(m.Verbs)+len(m.Subjects) > 0 } + +// marked is what an entry's manifest says of its marks: `needs-approval`, its events and its streams. +type marked struct { + NeedsApproval []string `json:"needs-approval"` + Emits []string `json:"emits"` + // Streams are read for their events and whether each carries a `grant`, and no other word, so a word added to a + // stream's declaration later never hides its marks. + Streams []struct { + Events []string `json:"events"` + Grant json.RawMessage `json:"grant"` + } `json:"streams"` +} + +// Derive is a module's marks from its served entry and its newest one, unioned while they differ so a mark is never lost +// before or after the module moves (§5, ADR 0286 §3), and the seat definitions' verb marks for every verb an entry serves, +// which no holder unmarks (§2, §3). fingerprint is the build its bundle serves; empty, or an entry not read, leaves the +// marks of the newest entry and the seats, with Unread saying why. +func Derive(c Catalog, module, fingerprint string) Marks { + m := Marks{Module: module, Tools: map[string]string{}, Verbs: map[string]string{}, Subjects: map[string]string{}, + Revision: c.Revision()} + defs := c.Definitions() + var entries []catalogseat.CatalogEntry + if fingerprint == "" { + m.Unread = "its bundle's environment carries no fingerprint, so the build it serves cannot be read" + } else if e, ok, why := c.Served(fingerprint); ok { + entries, m.Served = append(entries, e), fingerprint + } else { + m.Unread = fmt.Sprintf("the catalog half's entry of %s cannot be read: %s", fingerprint, why) + } + if e, ok := c.Newest(module); ok && (len(entries) == 0 || e.Fp != entries[0].Fp) { + entries = append(entries, e) + } + for _, e := range entries { + source := "entry " + e.Module + " " + orNone(e.Fp) + var said marked + if raw, err := json.Marshal(e.Manifest); err == nil { + _ = json.Unmarshal(raw, &said) + } + serves := map[string]bool{} + for _, sv := range e.Serves { + serves[sv] = true + seat, verb, _ := strings.Cut(sv, ".") + for _, v := range defs[seat].Verbs { + if v.Name == verb && v.NeedsApproval { + m.Verbs[sv] = "seat " + seat + } + } + } + events := map[string]bool{} + for _, ev := range said.Emits { + events[ev] = true + } + for _, s := range said.Streams { + for _, ev := range s.Events { + events[ev] = true + } + } + for _, name := range said.NeedsApproval { + switch { + case serves[name]: + if _, bySeat := m.Verbs[name]; !bySeat { + m.Verbs[name] = source + } + case events[name]: + for _, s := range eventSubjects(e.Module, name) { + m.Subjects[s] = source + } + default: + m.Tools[name] = source + } + } + for _, s := range said.Streams { + if len(s.Grant) == 0 || string(s.Grant) == "null" { + continue + } + wire := make([]string, 0, len(s.Events)) + for _, ev := range s.Events { + wire = append(wire, buscompose.WirePattern(ev)) + } + for _, sub := range (buscompose.ModuleStream{Module: e.Module, Events: wire}).Subjects() { + m.Subjects[sub] = source + if !strings.HasSuffix(sub, ">") { + m.Subjects[sub+".>"] = source + } + } + } + } + return m +} + +// eventSubjects are a marked event's subjects on the bus (design 34 every-event §33): said on the module's events and on its +// own stream, each also with every subject below it. +func eventSubjects(module, event string) []string { + ev := buscompose.WirePattern(event) + out := []string{"mesh.mod." + module + ".event." + ev, buscompose.ModuleStreamSubject(module, ev)} + if !strings.HasSuffix(ev, ">") { + out = append(out, out[0]+".>", out[1]+".>") + } + sort.Strings(out) + return out +} + +func orNone(s string) string { + if s == "" { + return "none" + } + return s +} + +// keysOf is a set's names, sorted; nil when empty. +func keysOf(set map[string]string) []string { + var out []string + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/node-tools/internal/approval/marks_test.go b/node-tools/internal/approval/marks_test.go new file mode 100644 index 0000000..85053f6 --- /dev/null +++ b/node-tools/internal/approval/marks_test.go @@ -0,0 +1,136 @@ +package approval + +import ( + "context" + "reflect" + "testing" + + condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" + "git.novox.be/novox/mesh-sdk/go/keys" + + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" +) + +// fakeCatalog is a catalog half as a fixture: entries by fingerprint, the newest by module, the seat definitions. +type fakeCatalog struct { + ready error + revision string + byFp map[string]catalogseat.CatalogEntry + newest map[string]catalogseat.CatalogEntry + defs map[string]seatdef.Definition +} + +func (c *fakeCatalog) Ready() error { return c.ready } +func (c *fakeCatalog) Revision() string { return c.revision } +func (c *fakeCatalog) Served(fp string) (catalogseat.CatalogEntry, bool, string) { + e, ok := c.byFp[fp] + return e, ok, "not-found" +} +func (c *fakeCatalog) Newest(m string) (catalogseat.CatalogEntry, bool) { + e, ok := c.newest[m] + return e, ok +} +func (c *fakeCatalog) Definitions() map[string]seatdef.Definition { return c.defs } + +func entry(fp string, serves []string, manifest map[string]any) catalogseat.CatalogEntry { + return catalogseat.CatalogEntry{Module: "shell", Fp: fp, Serves: serves, Manifest: manifest} +} + +// Design 46 7-approval-mark §5, §3, §4 and design 34 every-event §33: the marks a module's calls are gated by, derived from +// fixture catalog halves and served entries. +func TestTheMarks(t *testing.T) { + login := map[string]seatdef.Definition{"node-login-shell": {Seat: "node-login-shell", + Verbs: []seatdef.Verb{{Name: "execute", NeedsApproval: true}, {Name: "sessions"}}}} + v1 := entry("src2:1", []string{"node-login-shell.execute", "node-login-shell.sessions"}, + map[string]any{"needs-approval": []any{"wipe"}}) + v2 := entry("src2:2", []string{"node-login-shell.execute", "node-login-shell.sessions"}, + map[string]any{"needs-approval": []any{"erase", "node-login-shell.sessions"}}) + mail := catalogseat.CatalogEntry{Module: "mail", Fp: "src2:m", Manifest: map[string]any{ + "needs-approval": []any{"mail.received"}, "emits": []any{"mail.received"}, + "streams": []any{map[string]any{"name": "MAIL", "events": []any{"mail.**"}, "grant": map[string]any{"config": "readers", "events": "mail.{key}.**"}}}}} + for _, tc := range []struct { + name string + cat *fakeCatalog + module string + fp string + tools []string + verbs map[string]string + subjects []string + unread bool + }{ + {name: "a seat's mark stands for every holder, and the holder's own adds up", cat: &fakeCatalog{revision: "7", + byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell"}}, + {name: "a holder's needs-approval cannot unmark its seat's verb, and marks another", cat: &fakeCatalog{revision: "8", + byFp: map[string]catalogseat.CatalogEntry{"src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:2", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}}, + {name: "across a version change the union stands: a dropped mark is asked while the old bundle runs", cat: &fakeCatalog{revision: "9", + byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1, "src2:2": v2}, newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"erase", "wipe"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}}, + {name: "a served entry that cannot be read leaves the newest marks, every marked call refused", cat: &fakeCatalog{revision: "9", + newest: map[string]catalogseat.CatalogEntry{"shell": v2}, defs: login}, + module: "shell", fp: "src2:1", tools: []string{"erase"}, verbs: map[string]string{"node-login-shell.execute": "seat node-login-shell", + "node-login-shell.sessions": "entry shell src2:2"}, unread: true}, + {name: "a bundle with no fingerprint has no served entry", cat: &fakeCatalog{revision: "9", defs: login}, + module: "shell", unread: true}, + {name: "a marked event and a stream with a grant give their subjects and each .>", cat: &fakeCatalog{revision: "3", + byFp: map[string]catalogseat.CatalogEntry{"src2:m": mail}, newest: map[string]catalogseat.CatalogEntry{"mail": mail}}, + module: "mail", fp: "src2:m", subjects: []string{"mesh.mod.mail.event.mail.received", "mesh.mod.mail.event.mail.received.>", + "mesh.stream.mail.event.mail.>", "mesh.stream.mail.event.mail.received", "mesh.stream.mail.event.mail.received.>"}}, + } { + t.Run(tc.name, func(t *testing.T) { + m := Derive(tc.cat, tc.module, tc.fp) + if got := keysOf(m.Tools); !reflect.DeepEqual(got, tc.tools) { + t.Errorf("tools %v, want %v", got, tc.tools) + } + if tc.verbs == nil { + tc.verbs = map[string]string{} + } + if !reflect.DeepEqual(m.Verbs, tc.verbs) { + t.Errorf("verbs %v, want %v", m.Verbs, tc.verbs) + } + if got := keysOf(m.Subjects); !reflect.DeepEqual(got, tc.subjects) { + t.Errorf("subjects %v, want %v", got, tc.subjects) + } + if (m.Unread != "") != tc.unread || m.Revision != tc.cat.revision { + t.Errorf("unread %q, revision %q", m.Unread, m.Revision) + } + }) + } + // §4: a bundle whose marks name a tool it does not register is not loaded, its finding mark-unserved raised; a bundle that + // serves it clears it, and a catalog without the mark too. + t.Run("mark-unserved raised and cleared", func(t *testing.T) { + cat := &fakeCatalog{revision: "7", byFp: map[string]catalogseat.CatalogEntry{"src2:1": v1}, newest: map[string]catalogseat.CatalogEntry{"shell": v1}, defs: login} + w := &findings{} + g := New(Deps{Node: "n1", Runner: "node-tools", Catalog: cat, Findings: w}) + if got := g.Unserved("shell", "src2:1", []string{"other"}); !reflect.DeepEqual(got, []string{"wipe"}) || w.raised[keys.EscapeName("n1.shell")] != KindMarkUnserved { + t.Fatalf("unserved %v, findings %v", got, w.raised) + } + if got := g.Unserved("shell", "src2:1", []string{"wipe"}); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" { + t.Fatalf("a bundle serving the mark: unserved %v, findings %v", got, w.raised) + } + _ = g.Unserved("shell", "src2:1", nil) + cat.byFp["src2:1"], cat.newest["shell"] = entry("src2:1", nil, nil), entry("src2:1", nil, nil) + if got := g.Unserved("shell", "src2:1", nil); got != nil || w.raised[keys.EscapeName("n1.shell")] != "" { + t.Fatalf("a catalog without the mark: unserved %v, findings %v", got, w.raised) + } + }) +} + +// findings is the runner's held writer as a fixture: each finding raised now, by id. +type findings struct{ raised map[string]string } + +func (f *findings) Write(_ context.Context, x condseat.Finding) error { + if f.raised == nil { + f.raised = map[string]string{} + } + f.raised[x.ID] = x.Kind + return nil +} +func (f *findings) Clear(_ context.Context, _, id, _, _ string) error { + delete(f.raised, id) + return nil +} diff --git a/node-tools/internal/firstview/approval.go b/node-tools/internal/firstview/approval.go new file mode 100644 index 0000000..bf317d6 --- /dev/null +++ b/node-tools/internal/firstview/approval.go @@ -0,0 +1,83 @@ +package firstview + +import ( + "context" + "strconv" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + catalogseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-sdk/go/seatdef" + + "github.com/novox/mesh-tools/node-tools/internal/approval" +) + +// Findings is the runner's held finding writer, the one the gate writes its findings through too (design 46 7-approval-mark +// §4, §16, §19); nil when it was not made. +func (v *Views) Findings() approval.FindingWriter { + if v.deps.Findings == nil { + return nil + } + return v.deps.Findings +} + +// ApprovalCatalog is the catalog half as the gate reads its marks (design 46 7-approval-mark §5): the entries the branch +// follower holds, an entry by fingerprint asked of `mesh-catalog.entry` when no branch lists it, and the seat definitions +// the seat list holds (defs). +func (v *Views) ApprovalCatalog(defs func() map[string]seatdef.Definition) approval.Catalog { + return approvalCatalog{v: v, defs: defs} +} + +type approvalCatalog struct { + v *Views + defs func() map[string]seatdef.Definition +} + +func (c approvalCatalog) Ready() error { + if c.v.branches == nil { + return errNotFollowed + } + return c.v.branches.Ready() +} + +func (c approvalCatalog) Revision() string { + if c.v.branches == nil { + return "" + } + for _, branch := range c.v.branches.Followed() { + if cat, ok := c.v.branches.Catalog(branch); ok { + if half, ok := cat.View(); ok { + return strconv.FormatInt(half.Revision.Revision, 10) + } + } + } + return "" +} + +func (c approvalCatalog) Served(fingerprint string) (catalogseat.CatalogEntry, bool, string) { + if c.v.branches == nil { + return catalogseat.CatalogEntry{}, false, errNotFollowed.Error() + } + e, state := c.v.branches.Resolve(context.Background(), fingerprint) + return e, state == meshcatalogclient.EntryAnswered, string(state) +} + +func (c approvalCatalog) Newest(module string) (catalogseat.CatalogEntry, bool) { + if c.v.branches == nil { + return catalogseat.CatalogEntry{}, false + } + e, ok, err := c.v.branches.Entry(module) + return e, ok && err == nil +} + +func (c approvalCatalog) Definitions() map[string]seatdef.Definition { + if c.defs == nil { + return nil + } + return c.defs() +} + +type notFollowed struct{} + +func (notFollowed) Error() string { return "the catalog half is not followed by this runner" } + +var errNotFollowed error = notFollowed{} diff --git a/node-tools/internal/firstview/firstview.go b/node-tools/internal/firstview/firstview.go index 9e60efd..c187bcb 100644 --- a/node-tools/internal/firstview/firstview.go +++ b/node-tools/internal/firstview/firstview.go @@ -28,6 +28,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" + "github.com/novox/mesh-tools/node-tools/internal/approval" "github.com/novox/mesh-tools/node-tools/internal/bus" ) @@ -120,7 +121,7 @@ func DepsOf(conn *bus.Conn, logf func(string, ...any)) Deps { } w, err := views.NewHeldWriter(views.WriterConfig[condseat.Finding, condseat.FindingCleared, condseat.FindingsStated, condseat.FindingsAskedEvent]{ Asked: condseat.SubjectFindingsAsked, Module: me, Node: conn.Node(), State: findingsState{conn: conn, module: me}, JS: js, Logf: logf, - Kinds: []string{views.UnansweredKind}}) + Kinds: append([]string{views.UnansweredKind}, approval.Kinds...)}) if err != nil { logf("[mesh-tools] the runner's findings are not written (%v): the first views' findings are in the log only", err) return d diff --git a/node-tools/internal/runtime/gate.go b/node-tools/internal/runtime/gate.go new file mode 100644 index 0000000..ed7cd5f --- /dev/null +++ b/node-tools/internal/runtime/gate.go @@ -0,0 +1,36 @@ +package runtime + +import ( + "github.com/novox/mesh-tools/node-tools/internal/approval" + "github.com/novox/mesh-tools/node-tools/internal/launch" +) + +// FingerprintWord is the word of a module's composed environment that names the build its bundle serves (design 47 G §424). +const FingerprintWord = "MESH_FINGERPRINT" + +// Gate is the approval gate the runner serves through (design 46 7-approval-mark §4, §20; node-tools/internal/approval). +type Gate interface { + // Unserved is, of a module's marked tools, those its bundle does not register, saying it as the module's finding. + Unserved(module, fingerprint string, registered []string) []string +} + +// WithGate serves every call through the approval gate. +func WithGate(g Gate) Option { return func(c *consumers) { c.gate = g } } + +var _ Gate = (*approval.Gate)(nil) + +// unservedMarks is, of the module's marked tools, those its bundle's registrations under its own name do not register. +func (r *runner) unservedMarks(module string, regs []launch.Registration) []string { + if r.events.gate == nil { + return nil + } + var registered []string + for _, reg := range regs { + if reg.Module == module { + for _, t := range reg.Tools { + registered = append(registered, t.Name) + } + } + } + return r.events.gate.Unserved(module, r.fingerprintOf(module), registered) +} diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index f3bc178..7dae3af 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -230,6 +230,10 @@ type runner struct { of map[string]*loadedModule verbs map[string]func() // each module's `tools` verb, where it is answered + // fingerprintOf is the build a module's bundle serves: the fingerprint its composed environment carries (design 47 G + // §424, §618), never the runner's own. + fingerprintOf func(module string) string + seatsAgain func() stops []func() // the watch, the announcement and the seats, in the order they began } @@ -263,6 +267,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log base := os.Environ() r.envFor = func(module string) []string { return BundleEnv(base, envs[module], module, node) } + r.fingerprintOf = func(module string) string { return envs[module][FingerprintWord] } // The module's events, for every child of it that subscribes (ADR 0198): one consumer per module, // bound the first time any of its children subscribes, each event handed to every child that did. @@ -409,6 +414,13 @@ func (r *runner) load(s Served) *loadedModule { fail(clashed) continue } + // A bundle whose marks name a tool it does not register is not loaded: no tool of its module is served here, rather + // than one served unmarked that the manifest meant to mark (design 46 7-approval-mark §4). + if unserved := r.unservedMarks(module, child.Registrations); len(unserved) > 0 { + child.Stop() + fail(fmt.Sprintf("its marks name %s, which it does not register (design 46 7-approval-mark §4)", strings.Join(unserved, ", "))) + continue + } l.children = append(l.children, child.Stop) for _, reg := range child.Registrations { got := registration{module: reg.Module, owner: module, tools: reg.Tools} @@ -772,6 +784,8 @@ type consumers struct { calls callsInFlight // sessions are the bundles' put and get sessions (sessions.go). sessions sessionTable + // gate is the approval gate every call is served through (gate.go); nil serves every call as before. + gate Gate } type moduleEvents struct {