Files
mesh-sdk/go/catalogue/recreates.go
T

333 lines
11 KiB
Go

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)
}
default:
// Anything that is not a file or directory (judged above by whether a container mounts it
// or it holds data) always holds: an archive, a package, a user, a network, or any type
// added later (novox/hq ADR 0377 §3's allowlist names only tools bundles and manifest
// fields that recreate nothing).
return fmt.Sprintf("it changes the %s %s", 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:<id>}`.
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:<id>}` 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
}