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.
97 lines
4.5 KiB
Go
97 lines
4.5 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// novox/hq ADR 0377 §3: a move under a module's own `record` rolls out when it recreates none of its containers
|
|
// and moves none of its data, judged from the two builds' manifests.
|
|
func TestWhatAMoveUnderRecordHoldsFor(t *testing.T) {
|
|
base := func() Manifest {
|
|
raw := `{"module":"mailu","tools":["mailu_status"],
|
|
"requires":["postgres-database"],
|
|
"data":{"own":[{"id":"mail","path":"${dir:data-mail}","class":"valuable"}]},
|
|
"bundles":[{"name":"tools","source":"store/mailu-tools@sha256:aaa","digest":"sha256:aaa"}],
|
|
"resources":[
|
|
{"id":"state","type":"directory"},
|
|
{"id":"mesh-state","type":"directory"},
|
|
{"id":"data-mail","type":"directory"},
|
|
{"id":"config-env","type":"file","path":"${dir:state}/mailu.env","content":"A=1"},
|
|
{"id":"runtime-config","type":"file","path":"${dir:mesh-state}/config.json","content":"{}"},
|
|
{"id":"inbox-note","type":"file","path":"${dir:data-mail}/README","content":"x"},
|
|
{"id":"imap","type":"container","image":"registry/imap@sha256:1","volumes":["${dir:data-mail}:/mail"],
|
|
"env-file":["${dir:state}/mailu.env"]},
|
|
{"id":"watcher","type":"process","command":"watch"}]}`
|
|
var m Manifest
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return m
|
|
}
|
|
resource := func(m *Manifest, id string) map[string]any {
|
|
for _, r := range m.Resources {
|
|
if r["id"] == id {
|
|
return r
|
|
}
|
|
}
|
|
t.Fatalf("no resource %s", id)
|
|
return nil
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
change func(m *Manifest)
|
|
holds string // empty: it rolls out
|
|
}{
|
|
{"nothing", func(*Manifest) {}, ""},
|
|
{"a tools bundle", func(m *Manifest) {
|
|
m.Bundles[0].Digest, m.Bundles[0].Source = "sha256:bbb", "store/mailu-tools@sha256:bbb"
|
|
}, ""},
|
|
{"the tool list", func(m *Manifest) { m.Tools = append(m.Tools, "mailu_queue") }, ""},
|
|
{"a file no container mounts", func(m *Manifest) { resource(m, "runtime-config")["content"] = `{"a":1}` }, ""},
|
|
{"a container's image", func(m *Manifest) { resource(m, "imap")["image"] = "registry/imap@sha256:2" }, "recreates 1 of mailu's 1"},
|
|
{"a container's declaration", func(m *Manifest) { resource(m, "imap")["health"] = map[string]any{"kind": "runtime"} }, "recreates 1"},
|
|
{"a container added", func(m *Manifest) {
|
|
m.Resources = append(m.Resources, map[string]any{"id": "smtp", "type": "container", "image": "x"})
|
|
}, "recreates"},
|
|
{"a mounted file", func(m *Manifest) { resource(m, "config-env")["content"] = "A=2" }, "which its container imap mounts"},
|
|
{"a file within declared data", func(m *Manifest) { resource(m, "inbox-note")["content"] = "y" }, "which its container imap mounts"},
|
|
{"a directory holding declared data", func(m *Manifest) { resource(m, "data-mail")["mode"] = "0700" }, "data-mail"},
|
|
{"the data declaration", func(m *Manifest) { m.Data.Own[0].Class = ClassIrreplaceable }, "data declaration"},
|
|
{"a provider", func(m *Manifest) { m.Requires = append(m.Requires, "s3") }, "what it requires"},
|
|
{"what it provides", func(m *Manifest) { m.Provides = append(m.Provides, Offer{Name: "smtp"}) }, "what it provides"},
|
|
{"a process of a module with data", func(m *Manifest) { resource(m, "watcher")["command"] = "watch2" }, "long-running process watcher"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
from, to := base(), base()
|
|
c.change(&to)
|
|
got := Holds(from, to)
|
|
switch {
|
|
case c.holds == "" && got != "":
|
|
t.Fatalf("held: %s", got)
|
|
case c.holds != "" && !strings.Contains(got, c.holds):
|
|
t.Fatalf("holds %q; want it to say %q", got, c.holds)
|
|
}
|
|
})
|
|
}
|
|
// A data directory nothing mounts still holds its data.
|
|
from, to := base(), base()
|
|
from.Data.Own = append(from.Data.Own, DataItem{ID: "archive", Path: "${dir:mesh-state}/archive", Class: "valuable"})
|
|
to.Data = &Data{Own: append([]DataItem(nil), from.Data.Own...)}
|
|
resource(&to, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
|
resource(&from, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
|
resource(&to, "runtime-config")["content"] = "new"
|
|
if got := Holds(from, to); !strings.Contains(got, "lies within its data archive") {
|
|
t.Fatalf("a file within data nothing mounts: %q", got)
|
|
}
|
|
// A process of a module with no data of its own may change.
|
|
from, to = base(), base()
|
|
from.Data, to.Data = nil, nil
|
|
resource(&to, "watcher")["command"] = "watch2"
|
|
if got := Holds(from, to); got != "" {
|
|
t.Fatalf("a process of a module with no data held: %s", got)
|
|
}
|
|
}
|