package catalogue import ( "encoding/json" "fmt" "reflect" "sort" "strings" ) // What a move does to a module's containers (novox/hq ADR 0245). // // A container whose declaration changes is recreated, whatever changed in it: an image, an environment // variable, a health check adopted. A module whose containers are recreated in one send is down while // they start again — on 2026-10-07 a catalogue change that only adopted the images' own health checks // recreated nine of mail's containers at once, and the operator's phone could not reach the mail. So a // send says, per module, how many of its containers it recreates and whether any image changed, before // it is sent and in the plan that sent it. // Recreation is what moving a module from one build's manifest to another's does to its containers. type Recreation struct { // Containers is how many containers the new build declares. Containers int // Recreated are the ids of the containers whose declaration differs — changed, added or gone — // sorted. Recreated []string // Images are the ids among them whose image changed (or that are added or gone). Images []string } // Recreates compares two builds of a module, container by container, by resource id. func Recreates(from, to Manifest) Recreation { before := containersByID(from) after := containersByID(to) var r Recreation r.Containers = len(after) for id, now := range after { was, held := before[id] switch { case !held: r.Recreated = append(r.Recreated, id) r.Images = append(r.Images, id) case !sameDeclaration(was, now): r.Recreated = append(r.Recreated, id) if !sameDeclaration(was["image"], now["image"]) { r.Images = append(r.Images, id) } } } for id := range before { if _, kept := after[id]; !kept { r.Recreated = append(r.Recreated, id) r.Images = append(r.Images, id) } } sort.Strings(r.Recreated) sort.Strings(r.Images) return r } // SpecOnly is whether the move recreates containers without any new image: a change to how they are // declared only — a health check adopted, a variable — which a person may well not expect to interrupt. func (r Recreation) SpecOnly() bool { return len(r.Recreated) > 0 && len(r.Images) == 0 } // Say is the recreation in the mesh's words, for a module: empty when the move recreates nothing. func (r Recreation) Say(module string) string { if len(r.Recreated) == 0 { return "" } what := "with a new image" switch { case r.SpecOnly(): what = "no new image, only their declaration" case len(r.Images) < len(r.Recreated): what = fmt.Sprintf("%d with a new image", len(r.Images)) } whole := "" if len(r.Recreated) > 1 && len(r.Recreated) >= r.Containers { whole = ", every one at once: its service is interrupted until they are up again" } else if len(r.Recreated) > 1 { whole = ", at once: what they serve is interrupted until they are up again" } return fmt.Sprintf("recreates %d of %s's %d container(s) (%s: %s)%s", len(r.Recreated), module, r.Containers, what, strings.Join(r.Recreated, ", "), whole) } func containersByID(m Manifest) map[string]map[string]any { out := map[string]map[string]any{} for _, r := range m.Resources { if t, _ := r["type"].(string); t != "container" { continue } id, _ := r["id"].(string) out[id] = r } return out } // sameDeclaration compares two declarations as JSON, so a number read as an int and one read as a // float are one value. func sameDeclaration(a, b any) bool { ra, errA := json.Marshal(a) rb, errB := json.Marshal(b) if errA != nil || errB != nil { return reflect.DeepEqual(a, b) } var na, nb any _ = json.Unmarshal(ra, &na) _ = json.Unmarshal(rb, &nb) return reflect.DeepEqual(na, nb) } // What a move under a module's own `record` may do without a person (novox/hq ADR 0377). // // A module records because its manifest says so or because it keeps irreplaceable data: mail, whose containers // recreated together interrupt everyone's mail, and the database server, whose restart drops every consumer. That // hold also held the moves that interrupt nothing — on 2026-10-15 the database server moved with only its tools // bundle changed, its walk said it "recreates none of its containers", and the machine stayed behind until a // person pushed. So such a move rolls out like any other, and only a move that recreates a container or touches // data still waits. // Holds is why a move from one build's manifest to another's still waits for a person under a module's own // `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container // changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or // lies within a path of the module's declared data; no change to its `data` declaration, nor to what it provides, // requires or contributes; and, for a module that declares data of its own, no change to a long-running process. // Its tools bundles, a file nothing mounts that holds none of its data, and manifest fields that recreate // nothing — its tool list — may differ. func Holds(from, to Manifest) string { module := to.Module if module == "" { module = from.Module } if said := Recreates(from, to).Say(module); said != "" { return said } if !sameDeclaration(from.Data, to.Data) { return "it changes " + module + "'s data declaration" } for _, f := range []struct { what string was, s any }{ {"what it provides", from.Provides, to.Provides}, {"what it requires", from.Requires, to.Requires}, {"which of its requirements are optional", from.OptionalRequires, to.OptionalRequires}, {"what it contributes to a provider", from.Contributes, to.Contributes}, {"what it contributes to a provider", from.ContributesMany, to.ContributesMany}, // A setting is filled into the files the module writes: a changed default changes what a container may // mount without the file's own declaration changing. {"its settings, which the files it writes are filled from", from.Settings, to.Settings}, } { if !sameDeclaration(emptyAsNil(f.was), emptyAsNil(f.s)) { return "it changes " + f.what } } mounted := append(mountSources(from), mountSources(to)...) data := append(dataPaths(from), dataPaths(to)...) keepsData := (from.Data != nil && len(from.Data.Own) > 0) || (to.Data != nil && len(to.Data.Own) > 0) before, after := resourcesByID(from), resourcesByID(to) var changed []string for id, r := range after { if was, held := before[id]; !held || !sameDeclaration(was, r) { changed = append(changed, id) } } for id := range before { if _, kept := after[id]; !kept { changed = append(changed, id) } } sort.Strings(changed) dirs := absoluteDirectories(from, to) for _, key := range changed { for _, r := range []map[string]any{before[key], after[key]} { if r == nil { continue } kind, id := fmt.Sprint(r["type"]), fmt.Sprint(r["id"]) switch kind { case "file", "directory": for _, p := range pathsOf(r, dirs) { for _, m := range mounted { if pathsMeet(p, expandDir(m.source, dirs)) { return fmt.Sprintf("it changes the %s %s, which its container %s mounts", kind, id, m.container) } } for _, d := range data { if pathsMeet(p, expandDir(d.path, dirs)) { return fmt.Sprintf("it changes the %s %s, which holds or lies within its data %s", kind, id, d.id) } } } case "process", "service": if keepsData { return fmt.Sprintf("it changes its long-running %s %s, and it keeps data of its own", kind, id) } } } } return "" } type mountSource struct{ container, source string } // mountSources is every path a container of the module reads from its machine: its volumes' sources and its // environment files. func mountSources(m Manifest) []mountSource { var out []mountSource for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "container" { continue } id := fmt.Sprint(r["id"]) vols, _ := r["volumes"].([]any) for _, v := range vols { s, _ := v.(string) if mount := volumeMount.FindStringSubmatch(s); mount != nil { out = append(out, mountSource{id, mount[1]}) } } switch files := r["env-file"].(type) { case string: out = append(out, mountSource{id, files}) case []any: for _, f := range files { if s, ok := f.(string); ok { out = append(out, mountSource{id, s}) } } } } return out } type dataPath struct{ id, path string } func dataPaths(m Manifest) []dataPath { if m.Data == nil { return nil } var out []dataPath for _, it := range m.Data.Own { out = append(out, dataPath{it.ID, it.Path}) } return out } func resourcesByID(m Manifest) map[string]map[string]any { out := map[string]map[string]any{} for _, r := range m.Resources { out[fmt.Sprint(r["type"])+":"+fmt.Sprint(r["id"])] = r } return out } // absoluteDirectories is, per directory id, the absolute path a directory of either build states. func absoluteDirectories(ms ...Manifest) map[string]string { out := map[string]string{} for _, m := range ms { for _, r := range m.Resources { if fmt.Sprint(r["type"]) != "directory" { continue } if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { out[fmt.Sprint(r["id"])] = strings.TrimRight(p, "/") } } } return out } // pathsOf is every way a file or directory resource is named: its path, and a directory's `${dir:}`. func pathsOf(r map[string]any, dirs map[string]string) []string { var out []string if p, ok := r["path"].(string); ok && p != "" { out = append(out, expandDir(p, dirs)) } if fmt.Sprint(r["type"]) == "directory" { out = append(out, expandDir("${dir:"+fmt.Sprint(r["id"])+"}", dirs)) } return out } // expandDir writes a leading `${dir:}` as the directory's absolute path when one is stated, so a path named // either way is one path. func expandDir(p string, dirs map[string]string) string { p = strings.TrimRight(p, "/") if ref := dirPlain.FindStringSubmatch(p); ref != nil { if abs, ok := dirs[ref[1]]; ok { return abs } return p } if strings.HasPrefix(p, "${dir:") { end := strings.Index(p, "}") if abs, ok := dirs[p[len("${dir:"):end]]; ok { return abs + p[end+1:] } } return p } // pathsMeet is whether one path is the other or lies within it. func pathsMeet(a, b string) bool { if a == "" || b == "" { return false } return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/") } // emptyAsNil reads an empty list or map as nothing, so a field written empty is no change from one left out. func emptyAsNil(v any) any { rv := reflect.ValueOf(v) switch rv.Kind() { case reflect.Slice, reflect.Map: if rv.Len() == 0 { return nil } case reflect.Ptr: if rv.IsNil() { return nil } } return v }