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) }