mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group feat/no-change-takes-effect-unseen delivered: every member is delivered
Two incidents had one shape: a change took effect that nobody saw first (hq issues 241, 304). Three guards, each silent when nothing is at stake: - settings show [--history], and a set that answers each key it adds, changes and removes, and refuses a removal unless --replace; the replaced or cleared layer is kept in settings_history, in the same transaction as the write (migration 0078). - every send keeps a summary of what it sent (no file content), plan <node> --diff compares with it, and push with no machine is refused unless --all. - a push that would give a running container's mount another host directory holds that machine, naming the module, mount and both directories, until push <node> --move <module>; a named push's cascade is held the same way. Rebased onto main and fitted to it: the hold runs before the push says what it recreates, a whole push still says so first and leaves machines waiting for a gate, the verb's push with no machine is still --behind and a push still needs why. The verbs take plan diff, settings replace and history, and push move beside a machine, each refused where it cannot take effect.
191 lines
7.3 KiB
Go
191 lines
7.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// novox/hq ADR 0217: no change to a machine takes effect unseen.
|
|
|
|
// The layer of 2026-10-05: a media server's node layer, and the one that replaced it, which kept
|
|
// `places` and lost three of its four directories and every other key.
|
|
var before = map[string]any{
|
|
"puid": 1000.0, "pgid": 1000.0,
|
|
"expose": map[string]any{"32400": "anywhere"},
|
|
"places": map[string]any{
|
|
"config": map[string]any{"path": "/srv/media/config", "owner": "1000:1000"},
|
|
"data": map[string]any{"path": "/srv/media/data", "owner": "1000:1000"},
|
|
"transcode": map[string]any{"path": "/srv/media/temp", "owner": "1000:1000"},
|
|
},
|
|
}
|
|
|
|
func TestASettingThatKeepsAKeyAndLosesItsEntriesIsSaidToRemoveThem(t *testing.T) {
|
|
after := map[string]any{"places": map[string]any{
|
|
"previews": map[string]any{"path": "/srv/pool/previews", "owner": "1000:1000"},
|
|
}}
|
|
added, changed, removed := settingsChange(before, after)
|
|
if !reflect.DeepEqual(added, []string{"places.previews.owner", "places.previews.path"}) {
|
|
t.Errorf("added %v", added)
|
|
}
|
|
if len(changed) != 0 {
|
|
t.Errorf("changed %v", changed)
|
|
}
|
|
for _, lost := range []string{"expose.32400", "pgid", "places.config.path", "places.data.owner", "puid"} {
|
|
found := false
|
|
for _, r := range removed {
|
|
found = found || r == lost
|
|
}
|
|
if !found {
|
|
t.Errorf("removing %s was not said: %v", lost, removed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestASettingThatOnlyAddsOrChangesRemovesNothing(t *testing.T) {
|
|
after := map[string]any{
|
|
"puid": 1001.0, "pgid": 1000.0,
|
|
"expose": map[string]any{"32400": "anywhere"},
|
|
"places": map[string]any{
|
|
"config": map[string]any{"path": "/srv/media/config", "owner": "1000:1000"},
|
|
"data": map[string]any{"path": "/srv/media/data", "owner": "1000:1000"},
|
|
"transcode": map[string]any{"path": "/srv/media/temp", "owner": "1000:1000"},
|
|
"previews": map[string]any{"path": "/srv/pool/previews", "owner": "1000:1000"},
|
|
},
|
|
}
|
|
_, changed, removed := settingsChange(before, after)
|
|
if len(removed) != 0 {
|
|
t.Errorf("an additive set was said to remove %v", removed)
|
|
}
|
|
if !reflect.DeepEqual(changed, []string{"puid"}) {
|
|
t.Errorf("changed %v", changed)
|
|
}
|
|
}
|
|
|
|
// What was sent, as a push would send it: a media server's container and a file with a secret.
|
|
func declaration(configFrom string, extra ...string) []byte {
|
|
vols := `"` + configFrom + `:/config", "/srv/media/data:/data"`
|
|
for _, e := range extra {
|
|
vols += `, "` + e + `"`
|
|
}
|
|
return []byte(`{"declaration":1,"sequence":7,"resources":[
|
|
{"id":"plex.server","type":"container","image":"plex@sha256:aa","volumes":[` + vols + `]},
|
|
{"id":"plex.env","type":"file","path":"/srv/media/env","content":"TOKEN=the-secret-itself"}]}`)
|
|
}
|
|
|
|
func summarized(t *testing.T, body []byte) []sentResource {
|
|
t.Helper()
|
|
s, err := summarize(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestWhatIsKeptOfASendHoldsNoFileContent(t *testing.T) {
|
|
s := summarized(t, declaration("/srv/media/config"))
|
|
var kept bytes.Buffer
|
|
for _, r := range s {
|
|
kept.WriteString(r.ID + r.Type + r.Digest + strings.Join(r.Volumes, ","))
|
|
for k, v := range r.Fields {
|
|
kept.WriteString(k + v)
|
|
}
|
|
}
|
|
if strings.Contains(kept.String(), "the-secret-itself") {
|
|
t.Fatal("a file's content was kept in the record of what was sent")
|
|
}
|
|
if len(s) != 2 || s[0].Volumes[0] != "/srv/media/config:/config" {
|
|
t.Fatalf("summary %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestADiffOfAnUnchangedMachineIsEmptyAndAChangedContainerNamesItsField(t *testing.T) {
|
|
was := summarized(t, declaration("/srv/media/config"))
|
|
if d := diffSent(was, summarized(t, declaration("/srv/media/config"))); !d.empty() {
|
|
t.Fatalf("an unchanged machine differs: %+v", d)
|
|
}
|
|
d := diffSent(was, summarized(t, declaration("/var/lib/plex/config")))
|
|
if len(d.Changed) != 1 || d.Changed[0].ID != "plex.server" || !reflect.DeepEqual(d.Changed[0].Fields, []string{"volumes"}) {
|
|
t.Fatalf("diff %+v", d)
|
|
}
|
|
var out bytes.Buffer
|
|
writeDiff(&out, "home", d, movesIn(was, summarized(t, declaration("/var/lib/plex/config"))))
|
|
for _, want := range []string{"~ plex.server recreated: volumes", "! plex.server: /config moves from /srv/media/config to /var/lib/plex/config"} {
|
|
if !strings.Contains(out.String(), want) {
|
|
t.Errorf("diff does not say %q:\n%s", want, out.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
// The incident: the configuration mount would move to an empty default directory.
|
|
func TestARunningContainersDataMovingIsAMoveAndAnAddedMountIsNot(t *testing.T) {
|
|
was := summarized(t, declaration("/srv/media/config"))
|
|
moves := movesIn(was, summarized(t, declaration("/var/lib/plex/config")))
|
|
want := []dataMove{{Container: "plex.server", Inside: "/config", From: "/srv/media/config", To: "/var/lib/plex/config"}}
|
|
if !reflect.DeepEqual(moves, want) {
|
|
t.Fatalf("moves %+v", moves)
|
|
}
|
|
if moves[0].Module() != "plex" {
|
|
t.Errorf("module %q", moves[0].Module())
|
|
}
|
|
// A mount added — the previews of 2026-10-05 — is not a move.
|
|
if m := movesIn(was, summarized(t, declaration("/srv/media/config", "/srv/pool/previews:/config/Media"))); len(m) != 0 {
|
|
t.Errorf("an added mount was held as a move: %+v", m)
|
|
}
|
|
// Nor is a container the machine never ran.
|
|
if m := movesIn(nil, was); len(m) != 0 {
|
|
t.Errorf("a first send was held as a move: %+v", m)
|
|
}
|
|
}
|
|
|
|
func TestAPushNamingNoMachineIsRefusedUnlessItSaysAll(t *testing.T) {
|
|
err := pushCommand(context.Background(), nil)
|
|
if err == nil || !strings.Contains(err.Error(), "--all") {
|
|
t.Fatalf("a bare push was not refused: %v", err)
|
|
}
|
|
if err := pushCommand(context.Background(), []string{"--all", "--behind"}); err == nil {
|
|
t.Fatal("--all with --behind was accepted")
|
|
}
|
|
}
|
|
|
|
func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) {
|
|
for _, c := range []struct {
|
|
verb string
|
|
args map[string]any
|
|
want []string
|
|
}{
|
|
{"settings", map[string]any{"module": "plex", "node": "home"}, []string{"settings", "show", "plex", "--node", "home"}},
|
|
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
|
|
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
|
|
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
|
|
[]string{"push", "home", "--wait", "0", "--move", "plex", "--why", "w"}},
|
|
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
|
|
{"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}},
|
|
} {
|
|
got, err := argvFor(c.verb, c.args)
|
|
if err != nil || !reflect.DeepEqual(got, c.want) {
|
|
t.Errorf("%s %v: %v %v, want %v", c.verb, c.args, got, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An argument of the guards given where it cannot take effect is refused, not passed over (issue 244):
|
|
// a move with no machine named, a replace beside a clear, a history beside values, a diff beside files.
|
|
func TestTheGuardsArgumentsAreNotPassedOver(t *testing.T) {
|
|
for _, c := range []struct {
|
|
verb string
|
|
args map[string]any
|
|
}{
|
|
{"push", map[string]any{"move": "plex", "why": "w"}},
|
|
{"settings", map[string]any{"module": "plex", "clear": "true", "replace": "true"}},
|
|
{"settings", map[string]any{"module": "plex", "values": "{}", "history": "true"}},
|
|
{"plan", map[string]any{"node": "home", "files": "true", "diff": "true"}},
|
|
} {
|
|
if argv, err := argvFor(c.verb, c.args); err == nil {
|
|
t.Errorf("%s %v was taken as %v, and an argument passed over", c.verb, c.args, argv)
|
|
}
|
|
}
|
|
}
|