Files
mesh-sdk/go/catalogue/groups_test.go
T
jschoubben b5d8eb03ce
mesh/merge-gate head b5d8eb03cea3 merged into trunk 863a981e014e — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head b5d8eb03cea3 merged into trunk 863a981e014e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-sdk@90b20f5a3bda (merged as d4deecdb into main, walk plan-179259365188251…
A module whose container names groups needs the container-groups capability, so it is held, naming it, on an engine that does not read them (issue 1638, ADR 0480 clause 7)
2026-10-21 15:03:32 +02:00

150 lines
6.8 KiB
Go

package catalogue
import (
"reflect"
"strings"
"testing"
)
// A container's supplementary groups may be an operator's value (novox/hq ADR 0480 clause 7, ADR 0348 §11,
// issue 1636): `groups: ["${config:media-gid}"]`, each element a whole placeholder or a whole number, filled at
// composition with the number the config value holds.
func aContainerWithGroups(groups ...any) map[string]any {
return map[string]any{"id": "server", "type": "container", "name": "n8n", "image": "x@sha256:" + strings.Repeat("a", 64),
"groups": groups}
}
// 1636/AC3: a manifest container with groups: ["${setting:media-gid}"] resolves to the number the setting says.
func TestAContainersGroupIsFilledFromTheSetting(t *testing.T) {
r := aContainerWithGroups("${setting:media-gid}", float64(2001))
layers := []Layer{{From: "ace", Values: map[string]any{"media-gid": float64(2000)}}}
if err := settingInto(r, layers, "n8n"); err != nil {
t.Fatal(err)
}
if want := []any{2000, 2001}; !reflect.DeepEqual(r["groups"], want) {
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
}
// A value the operator wrote as text is read as the number it spells.
r = aContainerWithGroups("${setting:media-gid}")
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"media-gid": "2000"}}}, "n8n"); err != nil {
t.Fatal(err)
}
if want := []any{2000}; !reflect.DeepEqual(r["groups"], want) {
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
}
}
// 1636/AC3: an unset value refuses the module by name, and the key and the remedy are named; nothing is dropped.
func TestAnUnsetGroupRefusesTheModuleByName(t *testing.T) {
r := aContainerWithGroups("${setting:media-gid}")
err := settingInto(r, nil, "n8n")
if err == nil || !strings.Contains(err.Error(), "n8n") || !strings.Contains(err.Error(), "media-gid") {
t.Fatalf("want a refusal naming n8n and media-gid, got %v", err)
}
if got, _ := r["groups"].([]any); len(got) != 1 || got[0] != "${setting:media-gid}" {
t.Fatalf("the resource was changed: %#v", r["groups"])
}
}
// 1636/AC2: a value that is not a whole number above zero cannot be a group.
func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) {
for _, bad := range []any{"media", "-1", "0", float64(0), float64(2000.5), "2000:2000", "", true} {
r := aContainerWithGroups("${setting:media-gid}")
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"media-gid": bad}}}, "n8n")
if err == nil || !strings.Contains(err.Error(), "media-gid") {
t.Errorf("value %#v: want a refusal naming the key, got %v", bad, err)
}
}
}
// A manifest in the config form registers with the group a config value fills, and the value is placed (a container's
// groups count as a place for it); a placeholder in any other shape is refused where the manifest is read.
func TestAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) {
m := map[string]any{
"module": "n8n", "version": "1",
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
"description": "the group that owns the media library on this machine"}},
"resources": []any{aContainerWithGroups("${config:media-gid}")},
}
if _, err := parseRaw(t, m); err != nil {
t.Fatalf("refused: %v", err)
}
for _, shape := range []string{"x${config:media-gid}", "${config:media-gid}:1", "${config:other}"} {
m["resources"] = []any{aContainerWithGroups(shape)}
if _, err := parseRaw(t, m); err == nil {
t.Errorf("groups %q registered", shape)
}
}
}
// 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value
// says, written as a number, and the module is left out by name when the machine says none.
func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) {
manifest, err := parseRaw(t, map[string]any{
"module": "n8n", "version": "1",
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
"description": "the group that owns the media library on this machine"}},
"resources": []any{aContainerWithGroups("${config:media-gid}")},
})
if err != nil {
t.Fatal(err)
}
r := anAdoptedAnchor()
r.Modules = append(r.Modules, manifest)
with := anchorRendering(false)
with.Settings["n8n"] = []Layer{{From: "anchor", Values: map[string]any{"media-gid": float64(2000)}}}
composed, err := r.Compose(with)
if err != nil {
t.Fatal(err)
}
if why, left := composed.LeftOut["n8n"]; left {
t.Fatalf("left out: %s", why)
}
if got := byID(composed.Resources)["n8n.server"]["groups"]; !reflect.DeepEqual(got, []any{2000}) {
t.Fatalf("composed groups %#v", got)
}
without := anchorRendering(false)
composed, err = r.Compose(without)
if err == nil {
why, left := composed.LeftOut["n8n"]
if !left || !strings.Contains(why, "media-gid") {
t.Fatalf("a machine that says no group kept n8n or left it out without the key: %q (%v)", why, left)
}
} else if !strings.Contains(err.Error(), "media-gid") {
t.Fatalf("refused without the key: %v", err)
}
}
// The node-engine reads a container's groups only when it reports the capability (novox/hq ADR 0480 clause 7, issue 1638):
// a module with a container that names groups is hosted on a machine that reports container-groups, and left out of one
// that does not, the capability named, so the field is never sent to an engine that would refuse it.
func TestAModuleWhoseContainerNamesGroupsIsHeldOnAMachineThatDoesNotReadThem(t *testing.T) {
n8n := Manifest{Module: "n8n", Resources: []map[string]any{aContainerWithGroups("${config:media-gid}")}}
plain := Manifest{Module: "plain", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "p", "image": "x@sha256:" + strings.Repeat("b", 64)}}}
for _, m := range []Manifest{n8n, plain} {
if got := impliedCapabilities(m); (m.Module == "n8n") != reflect.DeepEqual(got, []string{ContainerGroups}) {
t.Errorf("%s implies %v", m.Module, got)
}
}
shelf := shelf(n8n, plain)
reads := Node{Name: "ace", Capabilities: map[string]bool{"container-runtime": true, ContainerGroups: true}}
got, err := Resolve(shelf, []string{"n8n", "plain"}, reads, World{})
if err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 2 {
t.Fatalf("a machine that reads groups: %v, held %+v, hosted %v", err, got.Unhostable, names(got))
}
old := Node{Name: "g14", Capabilities: map[string]bool{"container-runtime": true}}
got, err = Resolve(shelf, []string{"n8n", "plain"}, old, World{})
if err != nil {
t.Fatal(err)
}
if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "n8n" || !reflect.DeepEqual(got.Unhostable[0].Missing, []string{ContainerGroups}) {
t.Fatalf("n8n was not held, naming the capability: %+v", got.Unhostable)
}
if h := names(got); len(h) != 1 || h[0] != "plain" {
t.Errorf("the module without groups is not the one hosted: %v", h)
}
}