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