Files
mesh-sdk/go/delivery/judge.go
T
jschoubben 6bce1fb0d2 Add the node-engine verb composition to the seat's contract, and the resources two compositions differ in (issue 774)
A provider waiting for a yes said why only in its have's words; nobody could read the composition that waits beside
what runs. The engine's read verb composition answers that, and ChangedResources lists what a move changes, a
received file naming the consumers that join or leave.
2026-10-31 00:23:42 +01:00

366 lines
12 KiB
Go

package delivery
import (
"bytes"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that
// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from
// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until
// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code.
//
// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or
// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed
// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its
// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running
// processes changes; **neither** otherwise, and for a module the node has never run.
// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7:
// a file change and a reload of the bus, no move of it, never asked).
type Judged struct {
Judgement Judgement
Words string
UserListOnly bool
}
// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the
// node runs no composition of the module. A composition equal to the one running (resource for resource, each
// canonical) is unchanged: no move, and no ask (ADR 0547 §3).
func JudgeMove(running *Composed, composed Composed) Judged {
if composed.Module == "" {
return Judged{Judgement: JudgedNeither, Words: "the node's own composition"}
}
if running == nil {
return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module}
}
if running.Data == composed.Data && SameComposedResources(*running, composed) {
return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"}
}
if onlyTheUserList(*running, composed) {
return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true}
}
j, words := judged(*running, composed)
return Judged{Judgement: j, Words: words}
}
// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `<module>.bus-users`.
const busUsersSuffix = ".bus-users"
// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list.
func onlyTheUserList(from, to Composed) bool {
if from.Data != to.Data {
return false
}
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
if len(before) != len(after) {
return false
}
changed := false
for id, now := range after {
was, ok := before[id]
if !ok {
return false
}
if bytes.Equal(was.raw, now.raw) {
continue
}
if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) {
return false
}
changed = true
}
return changed
}
// judged applies ADR 0377 §3 to two compositions of one module.
func judged(from, to Composed) (Judgement, string) {
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
}
places := dataPlaces(before, after)
held := func(kind string) bool {
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
}
var moved []string
for _, id := range changedOf(before, after, held) {
r := before[id]
if r == nil {
r = after[id]
}
if withinPlaces(r.path, places) {
moved = append(moved, id)
}
}
if to.Data || from.Data {
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
}
// A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
// a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
// moving its data). It is said loudly in the words, and never hides an item that moved.
if len(moved) > 0 {
sort.Strings(moved)
// A file the mesh makes for the module's provisions (what its consumers contribute, what it is bound to, the
// secret it is given) is said as the consumers that join or leave, not as its data moving (issue 774,
// 2026-10-31: postgres's yes said "where its data is kept" when nextcloud left its grants). Still a move: the
// yes it asks is what keeps a consumer's login while that consumer is held.
var kept, made []string
for _, id := range moved {
if who := consumersOf(id, before[id], after[id]); who != nil {
made = append(made, who...)
} else {
kept = append(kept, id)
}
}
var parts []string
if len(kept) > 0 {
parts = append(parts, "it changes where its data is kept: "+strings.Join(kept, ", "))
}
if len(made) > 0 {
parts = append(parts, "consumers join or leave: "+strings.Join(made, ", "))
}
words := strings.Join(parts, "; ")
if from.Data != to.Data {
words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
}
return JudgedMovesData, words
}
if from.Data != to.Data {
return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
"(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
}
return JudgedNeither, "it recreates no container and moves no data"
}
// ResourceChange is one resource that differs between two compositions of a module: its id, its kind, whether it is
// added, removed or changed, and for a file the mesh makes for a provision (received-, bound-, secret-) the consumers
// that join or leave, in words.
type ResourceChange struct {
ID string `json:"id"`
Kind string `json:"kind"`
Change string `json:"change"`
Consumers []string `json:"consumers,omitempty"`
}
// ChangedResources is every resource added, removed or changed from running to composed, by id, each compared
// canonical (issue 774: the engine's verb composition shows what a move waiting for a yes would change). Running nil
// is every resource added.
func ChangedResources(running *Composed, composed Composed) []ResourceChange {
var from Composed
if running != nil {
from = *running
}
before, after := judgedResourcesByID(from), judgedResourcesByID(composed)
var out []ResourceChange
for _, id := range changedOf(before, after, func(string) bool { return true }) {
was, now := before[id], after[id]
c := ResourceChange{ID: id, Change: "changed"}
switch {
case was == nil:
c.Change, c.Kind = "added", now.kind
case now == nil:
c.Change, c.Kind = "removed", was.kind
default:
c.Kind = now.kind
}
c.Consumers = consumersOf(id, was, now)
out = append(out, c)
}
return out
}
// consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID,
// BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given
// list's from and node; else that what it is given for the provision changes. Nil for any other resource.
func consumersOf(id string, before, after *judgedResource) []string {
_, local, _ := strings.Cut(id, ".")
r := after
if r == nil {
r = before
}
if r == nil || r.kind != string(declaration.TypeFile) {
return nil
}
if provision, ok := strings.CutPrefix(local, "received-"); ok {
was, now := givenIn(before), givenIn(after)
var out []string
for _, c := range sortedGiven(was) {
if !now[c] {
out = append(out, c+" leaves "+provision)
}
}
for _, c := range sortedGiven(now) {
if !was[c] {
out = append(out, c+" joins "+provision)
}
}
if len(out) == 0 {
out = append(out, "what "+provision+"'s consumers are given changes")
}
return out
}
for _, prefix := range []string{"bound-", "secret-"} {
if provision, ok := strings.CutPrefix(local, prefix); ok {
return []string{"what it is given for " + provision + " changes"}
}
}
return nil
}
// givenIn is the consumers a received file's content names, each as "<from> on <node>".
func givenIn(r *judgedResource) map[string]bool {
out := map[string]bool{}
if r == nil {
return out
}
var file struct {
Content string `json:"content"`
}
var received struct {
Given []struct {
From string `json:"from"`
Node string `json:"node"`
} `json:"given"`
}
if json.Unmarshal(r.raw, &file) != nil || json.Unmarshal([]byte(file.Content), &received) != nil {
return out
}
for _, g := range received.Given {
out[g.From+" on "+g.Node] = true
}
return out
}
func sortedGiven(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount,
// and its canonical bytes.
type judgedResource struct {
kind string
path string
mounts []string
raw []byte
}
func judgedResourcesByID(c Composed) map[string]*judgedResource {
out := map[string]*judgedResource{}
for _, raw := range c.Resources {
var head struct {
ID string `json:"id"`
Type string `json:"type"`
Path string `json:"path"`
// A container's volumes, `<path on the machine>:<path inside>[:<options>]`.
Volumes []string `json:"volumes"`
}
if err := json.Unmarshal(raw, &head); err != nil {
continue
}
r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)}
for _, v := range head.Volumes {
source, _, _ := strings.Cut(v, ":")
r.mounts = append(r.mounts, source)
}
out[head.ID] = r
}
return out
}
// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two
// compositions that say the same compare equal however each was written.
func compactResource(raw []byte) []byte {
var v any
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
if err := d.Decode(&v); err != nil {
return raw
}
out, err := json.Marshal(v)
if err != nil {
return raw
}
return out
}
// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted.
func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string {
var out []string
for id, now := range after {
if !counted(now.kind) {
continue
}
if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) {
out = append(out, id)
}
}
for id, was := range before {
if _, ok := after[id]; !ok && counted(was.kind) {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its
// directories, before and after.
func dataPlaces(before, after map[string]*judgedResource) []string {
var out []string
for _, rs := range []map[string]*judgedResource{before, after} {
for _, r := range rs {
if r.kind == string(declaration.TypeContainer) {
out = append(out, r.mounts...)
}
if r.kind == string(declaration.TypeDirectory) && r.path != "" {
out = append(out, r.path)
}
}
}
return out
}
// withinPlaces says p is one of places, or lies within one, or holds one.
func withinPlaces(p string, places []string) bool {
if p == "" {
return false
}
p = path.Clean(p)
for _, place := range places {
if place == "" || !strings.HasPrefix(place, "/") {
continue
}
place = path.Clean(place)
if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") {
return true
}
}
return false
}
// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared
// canonical.
func SameComposedResources(a, b Composed) bool {
if len(a.Resources) != len(b.Resources) {
return false
}
for i := range a.Resources {
if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) {
return false
}
}
return true
}