The part composed every membership with no root-free input, so telegram's verified-sender was dropped and the membership held as a shadow difference. It now asks mesh-controller.root-free for the machines a composition needs, per pass, failing closed and said once, and wakes on root-not-free condition-raised and condition-cleared.
257 lines
8.7 KiB
Go
257 lines
8.7 KiB
Go
package grants
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
|
)
|
|
|
|
// Memberships are published for real from this milestone (the operator, 2026-10-25 22:31): since release 6 nobody
|
|
// publishes them, so a module placed since cannot serve its seat or reach a new state. The user list, the assertions and
|
|
// the grants stay in shadow until take-over.
|
|
|
|
// MembershipGaps are what a membership carries that the part cannot compose from the bus.
|
|
var MembershipGaps = []Gap{
|
|
{Input: "membership: private network and received contributions", Why: "kept in each node's composition, which only that node reads: carried over from the membership last published, none for a first one"},
|
|
{Input: "membership: root-free machines", Why: "asked of the controller's root-free verb at each pass and kept for that pass alone; a machine it does not call free (an error included) is composed as not free, and the pass says why"},
|
|
}
|
|
|
|
// Publisher publishes a membership on the assignments stream (membership.Subject), last per subject, and reads the one
|
|
// last published there. A refusal (the grant not yet on the bus) is an error the part says and tries again at the next
|
|
// event.
|
|
type Publisher interface {
|
|
LastMembership(node, module string) (body []byte, seq uint64, found bool, err error)
|
|
PublishMembership(node, module string, body []byte, after uint64) error
|
|
}
|
|
|
|
// Membership is one module's membership on one node, as composed now, and what became of its publishing.
|
|
type Membership struct {
|
|
Node string `json:"node"`
|
|
Module string `json:"module"`
|
|
Digest string `json:"digest"`
|
|
Membership membership.Membership `json:"membership"`
|
|
// Published says what became of it at the last pass: "published", "unchanged" (equal to the one last published),
|
|
// or why it was not.
|
|
Published string `json:"published"`
|
|
// Retired are the seat verbs the last membership served that mesh-sdk's seat catalogue no longer has, `<seat>
|
|
// <verb>`: dropped on purpose, so their loss does not hold the membership back (issue 1945).
|
|
Retired []string `json:"retired,omitempty"`
|
|
}
|
|
|
|
// Memberships composes every assignment's membership from the records (buscompose.MembershipFor, as the controller's
|
|
// parked composition pass, mesh-controller 003eee8a, issued them), by `<node>.<module>`. Pure.
|
|
func Memberships(r buscompose.Records) map[string]Membership {
|
|
where := buscompose.PlacementsOf(r, r.Interchangeable)
|
|
out := map[string]Membership{}
|
|
for node, declared := range r.Assigned {
|
|
for _, d := range declared {
|
|
m := buscompose.MembershipFor(node, d, where)
|
|
out[node+"."+d.Module] = Membership{Node: node, Module: d.Module, Digest: digestOf(m), Membership: m}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func digestOf(m membership.Membership) string {
|
|
raw, _ := json.Marshal(m)
|
|
sum := sha256.Sum256(raw)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Published words of a membership's last pass.
|
|
const (
|
|
MembershipPublished = "published"
|
|
MembershipUnchanged = "unchanged"
|
|
)
|
|
|
|
// publish publishes m when it differs from the membership last published for its assignment, carrying that one's private
|
|
// network and received contributions; it answers m as published (or why not) and whether it was published now.
|
|
func publish(p Publisher, m Membership) (Membership, bool) {
|
|
if p == nil {
|
|
m.Published = "not published: no publisher here (only the part's process publishes)"
|
|
return m, false
|
|
}
|
|
was, seq, found, err := p.LastMembership(m.Node, m.Module)
|
|
if err != nil {
|
|
m.Published = "not published: the membership last published cannot be read: " + err.Error()
|
|
return m, false
|
|
}
|
|
if found {
|
|
var last membership.Membership
|
|
if json.Unmarshal(was, &last) == nil {
|
|
m.Membership.Mesh, m.Membership.Receives = last.Mesh, last.Receives
|
|
m.Digest = digestOf(m.Membership)
|
|
}
|
|
}
|
|
body, err := json.Marshal(m.Membership)
|
|
if err != nil {
|
|
m.Published = "not published: " + err.Error()
|
|
return m, false
|
|
}
|
|
if found && sameMembership(was, body) {
|
|
m.Published = MembershipUnchanged
|
|
return m, false
|
|
}
|
|
// Only a first membership, or one that only adds to the last, is published (the lead's decision, review of 1ff5049):
|
|
// composed with no root-free judgement, a kind's verified-sender would be dropped, and the operator's approvals by
|
|
// a channel would no longer be honoured. Any loss stays a shadow difference, named.
|
|
if found {
|
|
kept, retired := WithoutRetired(was)
|
|
m.Retired = retired
|
|
if lost := Losses(kept, body); len(lost) > 0 {
|
|
m.Published = ShadowDifference + strings.Join(lost, "; ")
|
|
return m, false
|
|
}
|
|
}
|
|
if err := p.PublishMembership(m.Node, m.Module, body, seq); err != nil {
|
|
m.Published = fmt.Sprintf("not published (tried again at the next event): %v", err)
|
|
return m, false
|
|
}
|
|
m.Published = MembershipPublished
|
|
return m, true
|
|
}
|
|
|
|
// WithoutRetired is a membership without the seat verbs it serves that mesh-sdk's seat catalogue no longer has (a verb
|
|
// retired on purpose, such as the delivery seat's mark-ready, ADR 0421), and those verbs, `<seat> <verb>`, once each. A
|
|
// seat the catalogue does not define (a module's own) keeps every verb, so its loss is still refused. Pure.
|
|
func WithoutRetired(last []byte) ([]byte, []string) {
|
|
var m map[string]any
|
|
if json.Unmarshal(last, &m) != nil {
|
|
return last, nil
|
|
}
|
|
seats, ok := m["seats"].([]any)
|
|
if !ok {
|
|
return last, nil
|
|
}
|
|
var kept []any
|
|
var retired []string
|
|
for _, e := range seats {
|
|
served, _ := e.(map[string]any)
|
|
seat, _ := served["seat"].(string)
|
|
verb, _ := served["verb"].(string)
|
|
if retiredVerb(seat, verb) {
|
|
if r := seat + " " + verb; !slices.Contains(retired, r) {
|
|
retired = append(retired, r)
|
|
}
|
|
continue
|
|
}
|
|
kept = append(kept, e)
|
|
}
|
|
if len(retired) == 0 {
|
|
return last, nil
|
|
}
|
|
if len(kept) == 0 {
|
|
delete(m, "seats") // as an empty list is written: not at all
|
|
} else {
|
|
m["seats"] = kept
|
|
}
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
return last, nil
|
|
}
|
|
sort.Strings(retired)
|
|
return out, retired
|
|
}
|
|
|
|
// retiredVerb says the seat catalogue defines seat with its verbs and no longer has verb among them. A seat the catalogue
|
|
// defines without verbs (its holder declares them) retires none.
|
|
func retiredVerb(seat, verb string) bool {
|
|
if seat == "" || verb == "" {
|
|
return false
|
|
}
|
|
s, ok := catalogue.SeatNamed(seat)
|
|
if !ok || len(s.Serves) == 0 {
|
|
return false
|
|
}
|
|
return !slices.ContainsFunc(s.Serves, func(v catalogue.Verb) bool { return v.Name == verb })
|
|
}
|
|
|
|
// sameMembership says two memberships say the same, whatever the order of their fields. Pure.
|
|
func sameMembership(was, now []byte) bool {
|
|
var a, b any
|
|
if json.Unmarshal(was, &a) != nil || json.Unmarshal(now, &b) != nil {
|
|
return false
|
|
}
|
|
return reflect.DeepEqual(a, b)
|
|
}
|
|
|
|
// ShadowDifference opens what a membership's pass says when it was not published because it would lose something.
|
|
const ShadowDifference = "not published, a shadow difference: publishing would lose "
|
|
|
|
// Losses names everything the last membership holds that now drops or weakens: a field gone, a value changed, a list
|
|
// element (a capability, a kind, a served address, a seat, a state, a stream) with no element in now that holds all of it.
|
|
// Empty means now holds all the last one did, and maybe more. Pure.
|
|
func Losses(last, now []byte) []string {
|
|
var a, b any
|
|
if json.Unmarshal(last, &a) != nil {
|
|
return nil
|
|
}
|
|
if json.Unmarshal(now, &b) != nil {
|
|
return []string{"the new membership is not JSON"}
|
|
}
|
|
return losses("", a, b)
|
|
}
|
|
|
|
func losses(path string, last, now any) []string {
|
|
switch l := last.(type) {
|
|
case map[string]any:
|
|
n, ok := now.(map[string]any)
|
|
if !ok {
|
|
return []string{fmt.Sprintf("%s: %s", name(path), brief(last))}
|
|
}
|
|
keys := make([]string, 0, len(l))
|
|
for k := range l {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var out []string
|
|
for _, k := range keys {
|
|
nv, ok := n[k]
|
|
if !ok {
|
|
out = append(out, fmt.Sprintf("%s: %s", name(path+"."+k), brief(l[k])))
|
|
continue
|
|
}
|
|
out = append(out, losses(path+"."+k, l[k], nv)...)
|
|
}
|
|
return out
|
|
case []any:
|
|
n, _ := now.([]any)
|
|
var out []string
|
|
for i, e := range l {
|
|
if !slices.ContainsFunc(n, func(c any) bool { return len(losses("", e, c)) == 0 }) {
|
|
out = append(out, fmt.Sprintf("%s[%d]: %s", name(path), i, brief(e)))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
if !reflect.DeepEqual(last, now) {
|
|
return []string{fmt.Sprintf("%s: %s, now %s", name(path), brief(last), brief(now))}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func name(path string) string {
|
|
if path == "" {
|
|
return "the membership"
|
|
}
|
|
return strings.TrimPrefix(path, ".")
|
|
}
|
|
|
|
// brief is a value in JSON, cut at 200 bytes.
|
|
func brief(v any) string {
|
|
raw, _ := json.Marshal(v)
|
|
if len(raw) > 200 {
|
|
return string(raw[:200]) + "…"
|
|
}
|
|
return string(raw)
|
|
}
|