diff --git a/go/compose/frombus/export.go b/go/compose/frombus/export.go index 996bb1e..ad81a77 100644 --- a/go/compose/frombus/export.go +++ b/go/compose/frombus/export.go @@ -61,11 +61,20 @@ func Export(in compose.Input, node string) (delivery.Placement, error) { } m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module} } + // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with + // the placement. A module in the config form has mesh-config's values laid into its layer, which are not. + settingsForm := !in.Catalogue[module].DeclaresConfig() for _, layer := range n.Settings[module] { if layer.From == catalogue.MeshWideLayer { m.HasMesh, m.MeshWide = true, layer.Placement + if settingsForm && len(layer.Values) > 0 { + m.MeshValues = layer.Values + } } else { m.HasLayer, m.Layer = true, layer.Placement + if settingsForm && len(layer.Values) > 0 { + m.Values = layer.Values + } } } p.Modules = append(p.Modules, m) diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go index b8ca0ef..d5ec534 100644 --- a/go/compose/frombus/frombus_test.go +++ b/go/compose/frombus/frombus_test.go @@ -71,6 +71,12 @@ func TestInput(t *testing.T) { t.Fatal(err) } digest := delivery.ManifestDigest(xterm) + // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its + // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. + media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + + `"name":{"kind":"preference","default":"x","why":"shown"}},"resources":[{"id":"conf","type":"file",` + + `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) + mediaDigest := delivery.ManifestDigest(media) keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} caps := []string{"container-runtime", "systemd", "wireguard"} tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", @@ -84,16 +90,19 @@ func TestInput(t *testing.T) { Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: overlay.Name}, - {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}}}, + {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}, + {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, + Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, }, records: map[string]delivery.NodeRecord{ "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, }, - modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "unread": {}}, - manifests: map[string][]byte{digest: xterm}, - values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}}, - own: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}}, + manifests: map[string][]byte{digest: xterm, mediaDigest: media}, + values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}, + "laptop.media": {"lines": "not mesh-config's"}}, + own: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, } } @@ -107,18 +116,24 @@ func TestInput(t *testing.T) { if err != nil { t.Fatal(err) } + parsedMedia, err := catalogue.ParseManifestForInputs(media) + if err != nil { + t.Fatal(err) + } capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true} tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} want := compose.Input{ - Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed}, + Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia}, Nodes: []compose.Node{ {Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true}, {Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here", - Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm"}, Capabilities: capsSet, - Ports: map[string]map[int]int{"xterm": {80: 8080}}, - Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}}}, + Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet, + Ports: map[string]map[int]int{"xterm": {80: 8080}}, + Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}, + "media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}}, + {From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}}, }, Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}}, OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "xterm", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, @@ -142,12 +157,13 @@ func TestInput(t *testing.T) { wj, _ := json.MarshalIndent(want, "", " ") t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj) } - if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm"}) { + if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead) } // The same composer over both: the compositions from the bus are the store's, byte for byte. - fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64)} + fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64), + "media": "src2:" + strings.Repeat("c", 64)} fromBus, _, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) if err != nil { t.Fatal(err) @@ -180,7 +196,7 @@ func TestInput(t *testing.T) { if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil { t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets) } - if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm"}) { + if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { // Every node's placed modules, and nothing placed nowhere: anchor places no xterm. t.Errorf("the check read %v", b.valuesRead) } diff --git a/go/delivery/placement.go b/go/delivery/placement.go index 293fb0e..99ed5de 100644 --- a/go/delivery/placement.go +++ b/go/delivery/placement.go @@ -83,14 +83,21 @@ func (p Provider) Chosen() catalogue.Chosen { return catalogue.Chosen{Node: p.No // layers, the mesh's first. Each node's file repeats the mesh-wide layer of the modules placed on it, so a node reads // its own key alone. HasLayer says the node has a layer of its own even when it holds no placement, since a layer // that is there and empty still carries the module's config values (Layers). +// +// MeshValues and Values are the config values of a module in the settings form (its manifest declares no config, so +// mesh-config holds none for it): what a person set in the whole mesh's layer and this node's, beside the placement +// (issue 1126; on 2026-10-26 the media modules on one node, plex's puid and pgid among them). A module in the config +// form takes its values from mesh-config and has none here. type PlacedModule struct { - Module string `json:"module"` - Ports map[string]int `json:"ports,omitempty"` - Bindings map[string]Provider `json:"bindings,omitempty"` - MeshWide map[string]any `json:"mesh-wide,omitempty"` - HasMesh bool `json:"has-mesh-wide,omitempty"` - Layer map[string]any `json:"layer,omitempty"` - HasLayer bool `json:"has-layer,omitempty"` + Module string `json:"module"` + Ports map[string]int `json:"ports,omitempty"` + Bindings map[string]Provider `json:"bindings,omitempty"` + MeshWide map[string]any `json:"mesh-wide,omitempty"` + HasMesh bool `json:"has-mesh-wide,omitempty"` + Layer map[string]any `json:"layer,omitempty"` + HasLayer bool `json:"has-layer,omitempty"` + MeshValues map[string]any `json:"mesh-wide-values,omitempty"` + Values map[string]any `json:"values,omitempty"` } // Holding is one seat a module placed on the node holds (catalogue.Held without its node, which is the file's). @@ -326,11 +333,22 @@ func (p Placement) HostPorts() map[string]map[int]int { // Layers is one module's settings layers on the node, as the composition reads them (compose.Node's Settings): the // whole mesh's layer when it has one, then the node's, which carries the module's config values as mesh-config resolved // them for this node (values, nil when none) beside its placement; a module with no layer and no value has none. This is -// the controller's SettingsFor for a module that takes its config values from mesh-config (issue 664). +// the controller's SettingsFor for a module that takes its config values from mesh-config (issue 664). A module in the +// settings form carries its values in the file (MeshValues, Values), which then stand for mesh-config's. func (m PlacedModule) Layers(node string, values map[string]any) []catalogue.Layer { var out []catalogue.Layer - if m.HasMesh || len(m.MeshWide) > 0 { - out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: map[string]any{}, Placement: m.MeshWide}) + if m.HasMesh || len(m.MeshWide) > 0 || len(m.MeshValues) > 0 { + mesh := map[string]any{} + for k, v := range m.MeshValues { + mesh[k] = v + } + out = append(out, catalogue.Layer{From: catalogue.MeshWideLayer, Values: mesh, Placement: m.MeshWide}) + } + if len(m.Values) > 0 { + values = make(map[string]any, len(m.Values)) + for k, v := range m.Values { + values[k] = v + } } if m.HasLayer || len(m.Layer) > 0 || len(values) > 0 { if values == nil {