The controller holds a module whose container names groups on a node that does not report container-groups, naming the capability (issue 1638, ADR 0480 clause 7)

This commit is contained in:
2026-10-21 14:30:26 +02:00
parent f10fbda1f6
commit d8d6d196a4
13 changed files with 247 additions and 11 deletions
@@ -0,0 +1,65 @@
package main
import (
"io"
"os"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Issue 1638 (novox/hq ADR 0480 clause 7), taken in with mesh-sdk's container `groups`: the controller sends a container's
// groups only to a node-engine that reports the capability container-groups, and holds the module on any other, naming it.
const n8nWithAGroup = `{"module":"n8n","version":"1",
"config":{"media-gid":{"type":"number","minimum":1,"required":true,"description":"the group that owns the media library"}},
"resources":[{"id":"server","type":"container","name":"n8n",
"image":"n8nio/n8n@sha256:4846eb2f4b874ab04cde7fc1e249d2ddaec66e9aea64439beb2972cfea88e3c0",
"groups":["${config:media-gid}"]}]}`
// printedBy runs reportUnhostable and returns what it printed.
func printedBy(t *testing.T, node string, plan catalogue.Resolution) string {
t.Helper()
stdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
reportUnhostable(node, plan)
w.Close()
os.Stdout = stdout
out, _ := io.ReadAll(r)
return string(out)
}
// 1638/AC1: a node that reports the capability gets the module; one that does not has it held, the capability named.
func TestAModuleWithGroupsIsHeldOnANodeThatDoesNotReportTheCapability(t *testing.T) {
m, err := catalogue.ParseManifest([]byte(n8nWithAGroup))
if err != nil {
t.Fatal(err)
}
shelf := catalogue.Shelf{"n8n": m}
reads := catalogue.Node{Name: "ace", Capabilities: map[string]bool{"container-runtime": true, "container-groups": true}}
old := catalogue.Node{Name: "g14", Capabilities: map[string]bool{"container-runtime": true}}
got, err := catalogue.Resolve(shelf, []string{"n8n"}, reads, catalogue.World{})
if err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 1 {
t.Fatalf("a node that reads groups: %v, held %+v", err, got.Unhostable)
}
if out := printedBy(t, "ace", got); out != "" {
t.Errorf("a node that reads groups was told: %q", out)
}
got, err = catalogue.Resolve(shelf, []string{"n8n"}, old, catalogue.World{})
if err != nil {
t.Fatal(err)
}
if len(got.Modules) != 0 || len(got.Unhostable) != 1 {
t.Fatalf("n8n was sent to a node that does not report container-groups: hosted %d, held %+v", len(got.Modules), got.Unhostable)
}
if out := printedBy(t, "g14", got); !strings.Contains(out, "n8n") || !strings.Contains(out, "container-groups") {
t.Errorf("the hold does not name the module and the capability: %q", out)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122745-e0e1a91496de
github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,7 +1,7 @@
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5 h1:E5stfIeCN6uYgA9TCv+4K8JgaURg3zQppvUgJLaDMP4=
git.novox.be/novox/mesh-host v0.0.0-20261018171808-9d9e3a46c4f5/go.mod h1:4wyoCr6IIIIarEUuROThWDj7DAvJvIidqMjWcpSnOwE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122745-e0e1a91496de h1:0iw6Xg/DzsvjIOGq2NE/YeLULuVG0Dv6eUKKMQS+4ys=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122745-e0e1a91496de/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+1 -1
View File
@@ -9,7 +9,7 @@ a second place to clean besides the catalogue itself.
mesh-catalog e0bc919d4945bb5f13f971f74643a0b714cf45da modules/*/module.json, modules/nats/Dockerfile
mesh-host 9d9e3a46c4f5943cfffb413d962ce48b75e2fd39 examples/foundation-first-node.lock
mesh-sdk 465beb4ea5e37eb36f6ba7fc60274de2d5a0d060 conformance/events/module-event.json
mesh-sdk e0e1a91496de28becd8387ac7acad4cdb85fdda2 conformance/events/module-event.json
To move them, from this repository's root, with the two repositories checked out beside it:
@@ -2214,6 +2214,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
problems = append(problems, replacesProblems(m)...)
problems = append(problems, UnitSettingProblems(m)...)
problems = append(problems, OwnerSettingProblems(m)...)
problems = append(problems, GroupSettingProblems(m)...)
problems = append(problems, endpointNameProblems(m)...)
problems = append(problems, RouteProblems(m)...)
for _, port := range m.Guards {
+18 -1
View File
@@ -904,7 +904,7 @@ func isModule(catalogue map[string]Manifest, want string) bool {
// machine can host the module as far as its capabilities go.
func missingCapabilities(m Manifest, node Node) []string {
var missing []string
for _, c := range m.Capabilities {
for _, c := range append(append([]string(nil), m.Capabilities...), impliedCapabilities(m)...) {
if !node.Capabilities[c] {
missing = append(missing, c)
}
@@ -1457,3 +1457,20 @@ func packageNames(m map[string][]string) []string {
sort.Strings(names)
return names
}
// ContainerGroups is the capability a node-engine reports when it reads a container's `groups` and passes each as a
// supplementary group (novox/hq ADR 0480 clause 7, issue 1638). An older engine refuses the key (strict decoding).
const ContainerGroups = "container-groups"
// impliedCapabilities are the capabilities a module needs for what its resources say, beyond those it declares: a
// container that names groups needs an engine that reads them, so the module is left out of a machine whose report
// lacks the capability, naming it, and the field is sent to no engine that would refuse it. None for a module whose
// containers name no groups.
func impliedCapabilities(m Manifest) []string {
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "container" && len(groupsOf(r)) > 0 {
return []string{ContainerGroups}
}
}
return nil
}
@@ -66,7 +66,7 @@ var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its
// contribution out on every machine, silently, so it is refused. A detector added there is added here.
var KnownCapabilities = []string{
"battery", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager",
"battery", "container-groups", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager",
"power-meter", "privileged", "seat", "service-manager", "uplink-dhcpcd", "uplink-networkmanager",
"uplink-systemd-networkd", "user-namespaces", "virtualisation",
}
@@ -85,6 +85,9 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
if fmt.Sprint(resource["type"]) == "directory" {
return settingIntoOwner(resource, layers, module)
}
if fmt.Sprint(resource["type"]) == "container" {
return settingIntoGroups(resource, layers, module)
}
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
@@ -343,6 +346,12 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
if unit, ok := r["unit"].(string); ok {
note(unit)
}
case "container":
for _, g := range groupsOf(r) {
if s, ok := g.(string); ok {
note(s)
}
}
}
}
inValues := func(values map[string]any) {
@@ -365,3 +374,100 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
}
return used
}
// A container's supplementary groups may be an operator's value (novox/hq ADR 0480 clause 7, ADR 0348 §11, issue 1636):
// n8n joins the media library's group, whose number is the machine's, never the definition's (ADR 0112 §1). Each
// element of `groups` is a whole placeholder or a whole number; a placeholder is filled with the number the
// config value holds, and the list reaches the node-engine as numbers.
// groupSide is one element of a container's groups: a whole placeholder or a whole number.
var groupSide = regexp.MustCompile(`^(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)$`)
// groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none.
func groupsOf(r map[string]any) []any {
groups, _ := r["groups"].([]any)
return groups
}
// GroupSettingProblems refuses, where a manifest is read, a container whose groups hold anything but whole
// placeholders and whole numbers, or a placeholder whose key the module neither declares nor asks for elsewhere.
func GroupSettingProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
for _, g := range groupsOf(r) {
s, isText := g.(string)
if !isText {
continue // a number: the declaration judges its value
}
if !groupSide.MatchString(s) {
problems = append(problems, fmt.Sprintf("%s: the container %v's group %q is not a whole "+
"${config:key} or a whole number: a supplementary group is a number, and nothing else is "+
"written into it (novox/hq ADR 0480 clause 7)", m.Module, r["id"], s))
continue
}
if m.Templated() {
continue
}
for _, key := range settingsUsed(s) {
if _, declared := m.Settings[key]; !declared && !m.DeclaresConfig() {
continue // the settings form judges its own keys
}
if _, declared := m.Settings[key]; !declared {
problems = append(problems, fmt.Sprintf("%s: the container %v's group says ${config:%s}, and the module "+
"declares no such value (novox/hq ADR 0348 §4)", m.Module, r["id"], key))
}
}
}
}
return problems
}
// settingIntoGroups fills ${setting:…} in a container's groups, refusing — with the key and why, the resource left as
// it was — a key nothing sets and a value that is not a whole number above zero. A number is kept; the list that
// results is numbers alone.
func settingIntoGroups(resource map[string]any, layers []Layer, module string) error {
groups := groupsOf(resource)
if len(groups) == 0 {
return nil
}
filled := make([]any, 0, len(groups))
for _, g := range groups {
s, isText := g.(string)
if !isText {
if f, isNumber := g.(float64); isNumber && f == float64(int(f)) {
g = int(f) // a number the manifest wrote, as the numbers a placeholder gives
}
filled = append(filled, g)
continue
}
keys := settingsUsed(s)
if len(keys) != 1 || settingRef.ReplaceAllString(s, "") != "" {
filled = append(filled, g) // not a whole placeholder: the manifest check refuses it
continue
}
key := keys[0]
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key,
where: fmt.Sprintf("a container %v whose groups", resource["id"]), setToday: orNoSettings(layers),
said: fmt.Sprintf(
"%s has a container %v whose groups say ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, resource["id"], key, key, module, key, orNoSettings(layers))}
}
v := plainly(value)
id, whole := wholeID(v)
if !whole || id == 0 {
return fmt.Errorf("%s: the setting %q is %q, which cannot be a group of the container %v: a supplementary "+
"group is a whole number above zero, never a name or other text (novox/hq ADR 0480 clause 7, ADR 0328 §15)",
module, key, v, resource["id"])
}
filled = append(filled, int(id))
}
resource["groups"] = filled
return nil
}
@@ -56,7 +56,7 @@ type filler struct {
// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here,
// or the sweep refuses the placeholder it would have filled.
var fillers = []filler{
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}, "container": {"groups"}}},
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
@@ -1454,6 +1454,14 @@ type Container struct {
// container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because
// a resource's Identity is its id.
RunAs *ContainerIdentity `json:"identity,omitempty"`
// Groups are the supplementary groups the container's process joins, as numbers, each passed to the runtime
// as a group to add (novox/hq ADR 0480 clause 7, issue 1636): n8n writes the media library through the
// library's group, which a stock image knows nothing of. Each is above zero and below the kernel's "no group"
// (ADR 0328 §15: never root). Part of the spec, so a change recreates the container. Absent: none are added.
// An older node-engine refuses the key (strict decoding), so the controller sends it only to a node that
// reports it reads it.
Groups []int64 `json:"groups,omitempty"`
}
// The ways an image is told who to run as (novox/hq ADR 0325 §2).
@@ -1521,6 +1529,30 @@ func (i *ContainerIdentity) problems(where string, env map[string]string) []stri
return problems
}
// groupProblems refuses a supplementary group that is root's or below it, the kernel's "no group" number, or a
// number named twice (novox/hq ADR 0480 clause 7, ADR 0328 §15).
func (c *Container) groupProblems(where string) []string {
var problems []string
seen := map[int64]bool{}
for _, g := range c.Groups {
switch {
case g == 0:
problems = append(problems, fmt.Sprintf("%s: container %s adds group 0, and the mesh never makes or keeps anything "+
"as root: a supplementary group is never root's (novox/hq ADR 0328 §15)", where, c.Name))
case g < 0:
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d, below zero: a group is a whole number "+
"above zero", where, c.Name, g))
case g >= 1<<32-1:
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d, which is the kernel's \"no group\" "+
"number or beyond it", where, c.Name, g))
case seen[g]:
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d twice", where, c.Name, g))
}
seen[g] = true
}
return problems
}
func (c *Container) Identity() string { return c.ID }
func (c *Container) Kind() Type { return TypeContainer }
func (c *Container) Target() string { return c.Name }
@@ -1603,6 +1635,7 @@ func (c *Container) validate(where string, _ bool) []string {
"runs and exits, and joins nothing afterwards")
}
problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...)
problems = append(problems, c.groupProblems(where)...)
return append(problems, checkImage(where, c.Image)...)
}
@@ -25,7 +25,7 @@ type BuildRecord struct {
}
// Artifact is one thing a build made: mesh-controller internal/chain/records.go Artifact, json keys name, kind and
// reference, nothing else (novox/hq ADR 0478 clause 7).
// reference, nothing else (novox/hq ADR 0478 clause 8).
type Artifact struct {
Name string `json:"name"`
Kind string `json:"kind"`
+16 -2
View File
@@ -389,11 +389,20 @@ type Module struct {
Src uint64 `json:"src,omitempty"`
Repository string `json:"repository"`
Commit string `json:"commit"`
// Path is the module's directory in its repository, trimmed of slashes, empty when its manifest is the repository's
// root (novox/hq ADR 0478 clause 8): the build tools need it. A value written before has none.
// Path is the module's directory in its repository, trimmed of slashes, and RootPath (".") when its manifest is the
// repository's root (novox/hq ADR 0478 clause 9, issue 1615): the build tools need it. Empty means not known: a value
// written before the indexer learned paths has none.
Path string `json:"path,omitempty"`
// Toolchains are the toolchains FP2 was computed with, by language (go, typescript): the version installed and the
// build module's recipe version the build nodes agreed on then (novox/hq ADR 0477 clause 2, ADR 0478 clause 3). A builder
// builds the fingerprint only with these. Empty when FP2 is empty or the module has no toolchain; a value written
// before has none (issue 1615).
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
}
// RootPath is Module.Path of a module whose manifest is its repository's root.
const RootPath = "."
// KeptInputs2 are SourceInputs2 without the module's name (the key has it) and without toolchains (those come from
// the build modules' toolchain keys when the fingerprint is computed): what a Module keeps of a merge.
type KeptInputs2 struct {
@@ -421,6 +430,11 @@ func (m Module) check() error {
case m.FP2 != "" && m.None2 != "":
return fmt.Errorf("delivery: a module has both fp2 and none2")
}
for language, t := range m.Toolchains {
if t.Version == "" || t.Recipe == "" {
return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language)
}
}
if m.Inputs2 != nil && m.Manifest == "" {
return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest")
}
+1 -1
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122745-e0e1a91496de
## explicit; go 1.26.0
git.novox.be/novox/mesh-sdk/go/asks
git.novox.be/novox/mesh-sdk/go/catalogue