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: `.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 " on ". 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, `:[:]`. 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 }