Under a module's own record (its manifest's, or its irreplaceable data's), every move waited for a person's push, even one that only changed a tools bundle: the database server stayed behind on ace on 2026-10-15 although its walk said it recreated none of its containers. ADR 0377 lets such a move roll out. A move is judged per machine against the build that machine was last sent: it goes when no container, mounted file, data path, data declaration, provider or (for a module with data) long-running process changes. A person's record, the bus and any move that cannot be judged still wait. The walk keeps per machine which way each recorded module went and why, says it in its note and log, and status names a machine behind with `push <node>` only for a move that waits.
327 lines
11 KiB
Go
327 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|