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