Bring F3 up to its release branch, with both sides' store tests (part 9 of 14)
mesh/merge-gate head fda71e5e5c4f merged into trunk fcdecb436117 — fail: head fda71e5e5c4f does not merge cleanly into trunk fcdecb436117: in conflict cmd/…
mesh/repo-check head fda71e5e5c4f merged into trunk fcdecb436117 — fail: head fda71e5e5c4f does not merge cleanly into trunk fcdecb436117: in conflict cmd/…
mesh/delivery superseded: a newer head of the same pull request

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 18:50:43 +00:00
co-authored by Claude Opus 5.5
parent 2d7ae1af8f
commit fda71e5e5c
8 changed files with 385 additions and 23 deletions
+65 -2
View File
@@ -34,6 +34,69 @@ const PlacesSetting = "places"
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
const AccessesSetting = "accesses"
// **Placement is not config** (novox/hq ADR 0348 clause 11, issue 651). `places`, `accesses` and
// `networks` are facts of an assignment the controller decides, held beside the assignment and apart
// from its settings: a Layer carries them in Placement, never in Values, and only Places, AccessPlaces
// and KeptNetworks read them. A person still says them as keys of a layer through `settings`; LayerOf
// splits such a layer, so what they type is routed to the placement and nothing they do changes.
// placementKeys are the keys of a layer that are placement, not config.
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true}
// SplitPlacement divides a layer as a person says it into its config values and its placement. Each is
// nil when it holds nothing; the values themselves are not copied.
func SplitPlacement(values map[string]any) (config, placement map[string]any) {
for key, value := range values {
if placementKeys[key] {
if placement == nil {
placement = map[string]any{}
}
placement[key] = value
continue
}
if config == nil {
config = map[string]any{}
}
config[key] = value
}
return config, placement
}
// LayerOf is a layer as a person says it — config values and placement in one map — split into the two.
func LayerOf(from string, values map[string]any) Layer {
config, placement := SplitPlacement(values)
if config == nil {
config = map[string]any{}
}
return Layer{From: from, Values: config, Placement: placement}
}
// Whole is the layer as a person says it: its values and its placement in one map, a new map.
func (l Layer) Whole() map[string]any {
out := make(map[string]any, len(l.Values)+len(l.Placement))
for k, v := range l.Values {
out[k] = v
}
for k, v := range l.Placement {
out[k] = v
}
return out
}
// asSaid is the layers with placement folded back into the values, for what reads a layer whole as it
// did while placement was kept in it: today's mergeable file, which takes every key (ADR 0326 §3), and
// the judgement that no value holds a line end.
func asSaid(layers []Layer) []Layer {
out := make([]Layer, len(layers))
for i, l := range layers {
out[i] = l
if len(l.Placement) > 0 {
out[i] = Layer{From: l.From, Values: l.Whole(), Default: l.Default}
}
}
return out
}
// Placement is what an assignment says about one of a module's directories.
type Placement struct {
// Path is where the directory is on this machine. Absolute.
@@ -208,7 +271,7 @@ func Places(m Manifest, layers []Layer) (map[string]Placement, error) {
}
out := map[string]Placement{}
for _, layer := range layers {
raw, ok := layer.Values[PlacesSetting]
raw, ok := layer.Placement[PlacesSetting]
if !ok {
continue
}
@@ -278,7 +341,7 @@ func AccessPlaces(m Manifest, layers []Layer) (map[string]string, error) {
}
out := map[string]string{}
for _, layer := range layers {
raw, ok := layer.Values[AccessesSetting]
raw, ok := layer.Placement[AccessesSetting]
if !ok {
continue
}
+41 -1
View File
@@ -23,7 +23,7 @@ func placeable() Manifest {
}
func placedBy(values map[string]any) Rendering {
return Rendering{Settings: SettingsBy{"arr": {{From: "node anchor", Values: values}}}}
return Rendering{Settings: SettingsBy{"arr": {LayerOf("node anchor", values)}}}
}
func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) {
@@ -179,3 +179,43 @@ func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) {
t.Fatalf("an access with no id and no path was accepted: %v", err)
}
}
// **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue
// 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A
// mergeable file of today's form still takes every key of the layer as it is said, placement too, so it
// composes as it did while placement was kept in the layer.
func TestPlacementIsReadFromThePlacementOnly(t *testing.T) {
m := placeable()
said := map[string]any{
PlacesSetting: map[string]any{"config": "/services/arr/config"},
AccessesSetting: map[string]any{"series": "/storage/media/series", "spool": "/storage/downloads"},
"log": "warn",
}
among := []Layer{{From: "anchor", Values: said}}
if placed, err := Places(m, among); err != nil || placed != nil {
t.Fatalf("a place among the config values placed %v (%v)", placed, err)
}
if _, _, err := accessesFor(m, among); err == nil {
t.Fatal("an access among the config values placed it")
}
beside := []Layer{LayerOf("anchor", said)}
if beside[0].Values["log"] != "warn" || beside[0].Values[PlacesSetting] != nil || beside[0].Placement[PlacesSetting] == nil {
t.Fatalf("LayerOf split %+v", beside[0])
}
if placed, err := Places(m, beside); err != nil || placed["config"].Path != "/services/arr/config" {
t.Fatalf("the placement did not place: %v (%v)", placed, err)
}
file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"}
whole, err := ApplySettings(file, among)
if err != nil {
t.Fatal(err)
}
split, err := ApplySettings(file, beside)
if err != nil {
t.Fatal(err)
}
if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"places"`) {
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
}
}
+13 -1
View File
@@ -130,7 +130,19 @@ var defaultSeats = append([]Seat{
// The vault: the controller seals every minted credential with what it provides, which is the
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
// second claimant, refused by name, rather than a candidate for a pin.
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161"},
//
// Its one verb, `get`, hands a secret on request and reply, sealed to the key the asker sends and only
// to an asker the vault allows (novox/hq ADR 0349 §11, issue 674). Optional until the vault that runs
// serves it (design 33 §7).
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
Serves: []Verb{
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
"sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " +
"bus proved it. Never the value in the clear, and never to a private key.",
Input: schema(map[string]string{"name": "the secret's name",
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
[]string{"name", "public_key"})},
}},
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
// images and archives, by digest — which is why the provision is the artifact store and not an
@@ -0,0 +1,194 @@
package catalogue
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
)
// influxdb and grafana as the catalogue declares them, cut to what composes the data source on a
// machine like ace (novox/hq issue 696): what influxdb serves for influxdb-api and its mergeable
// runtime configuration, and grafana's binding, data source file and credential.
const grafanaLikeTheCatalogue = `{
"module": "grafana", "version": "1",
"requires": [{"name": "influxdb-api", "optional": true}],
"contributes": {"influxdb-api": {"access": "read"}},
"binds": {"influxdb-api": "${dir:state}/influxdb.json"},
"secrets": {"influxdb-api": "${dir:mesh-state}/influxdb-api"},
"resources": [
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
{"id": "state", "type": "directory", "mode": "0700", "place": "."},
{"id": "influxdb-datasources", "type": "directory", "mode": "0755", "owner": "472:0"},
{"id": "influxdb-datasource", "type": "file", "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", "mode": "0644",
"content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n",
"only-when-bound": "influxdb-api"}
]
}`
// influxdbServing is influxdb as the catalogue declared it before issue 696 (literal served org and
// bucket) or after (served from its settings, today's literals as their defaults).
func influxdbServing(after bool) string {
served := `"org": "mesh", "bucket": "default"`
settings, takes := ``, ``
if after {
served = `"org": "${setting:org}", "bucket": "${setting:bucket}"`
settings = `"settings": {
"org": {"kind": "preference", "default": "mesh", "why": "the org influxdb makes at its first start"},
"bucket": {"kind": "preference", "default": "default", "why": "the bucket influxdb makes at its first start"}},`
takes = `, "takes-settings": ["bucket", "org", "url"], "trusted": true`
}
return `{
"module": "influxdb", "version": "1",
"provides": [{"name": "influxdb-api", "scope": "mesh", "identity": {"in": "an InfluxDB v1 authorization"}}],
"listens": [{"name": "api", "port": 8086, "protocol": "tcp", "from": "mesh", "why": "queries and writes"}],
` + settings + `
"serves": {"influxdb-api": {"scheme": "http", "port": 8086, ` + served + `}},
"resources": [
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
{"id": "runtime-config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600",
"content": "{}\n", "merge": "json"` + takes + `}
]
}`
}
// composedLikeAce is every resource of grafana and influxdb as one machine composes them, by id, with
// influxdb's settings layer on it.
func composedLikeAce(t *testing.T, influxdb string, layers []Layer) map[string]string {
t.Helper()
var ms []Manifest
for _, raw := range []string{influxdb, grafanaLikeTheCatalogue} {
m, err := ParseManifest([]byte(raw))
if err != nil {
t.Fatal(err)
}
ms = append(ms, m)
}
r, err := Resolve(shelf(ms...), []string{"influxdb", "grafana"}, reachable(), World{})
if err != nil {
t.Fatal(err)
}
out, err := r.Declaration(Rendering{Settings: SettingsBy{"influxdb": layers},
Needed: map[string]map[string]string{}})
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, res := range out {
id, _ := res["id"].(string)
if !strings.HasPrefix(id, "grafana.") && !strings.HasPrefix(id, "influxdb.") {
continue
}
b, err := json.Marshal(res)
if err != nil {
t.Fatal(err)
}
got[id] = string(b)
}
return got
}
// aceLikeLayer is a layer like influxdb's on ace: its own org and bucket (made up here), and the
// endpoint block the mesh reads itself.
func aceLikeLayer() []Layer {
return []Layer{{From: "ace", Values: map[string]any{
"org": "made-up-org", "bucket": "made-up-bucket",
"endpoints": map[string]any{"api": map[string]any{"label": "influx", "port": float64(8086), "reach": "internal"}},
}}}
}
// grafana composes byte for byte as it did before issue 696, on a machine like ace and on one
// without a layer, now that influxdb serves its org and bucket from its settings and no layer
// replaces a served key. The golden files are grafana's resources as the integration branch's
// controller composed them at 71362c1 (Settle still overriding), with influxdb serving the literals.
func TestGrafanaComposesTheSameOnceInfluxdbServesItsSettings(t *testing.T) {
for name, layers := range map[string][]Layer{"ace": aceLikeLayer(), "unlayered": nil} {
raw, err := os.ReadFile("testdata/influxdb-served-by-setting/grafana-" + name + ".json")
if err != nil {
t.Fatal(err)
}
var want map[string]json.RawMessage
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
got := composedLikeAce(t, influxdbServing(true), layers)
seen := 0
for id, v := range got {
if !strings.HasPrefix(id, "grafana.") {
continue
}
seen++
w, ok := want[id]
if !ok {
t.Errorf("%s: %s composes and did not before", name, id)
continue
}
var compact bytes.Buffer
if err := json.Compact(&compact, w); err != nil {
t.Fatal(err)
}
if compact.String() != v {
t.Errorf("%s: %s changed:\n got %s\nwant %s", name, id, v, compact.String())
}
}
if seen != len(want) {
t.Errorf("%s: %d of grafana's %d resources compose", name, seen, len(want))
}
}
}
// What a provider serves is its definition's, filled from settings only where it asks with
// ${setting:<key>} (novox/hq issue 696, ADR 0348 clause 11): a layer that sets a key the provider
// serves as a literal replaces nothing, and a key it does not serve is never added.
func TestALayerNoLongerReplacesAServedKey(t *testing.T) {
served, err := Settle(map[string]any{"host": "mail", "port": 25},
[]Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}})
if err != nil {
t.Fatal(err)
}
if served["host"] != "mail" {
t.Errorf("a layer replaced the served host: %v", served)
}
if _, leaked := served["sitename"]; leaked {
t.Errorf("a setting for a file reached the consumers: %v", served)
}
// And a layer setting it is said to reach nothing, rather than taken for a change.
m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"host": "mail"}}}
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"host": "post"}}}); len(unused) != 1 {
t.Errorf("a layer setting a literal served key was not said to reach nothing: %v", unused)
}
// influxdb as it was before issue 696, serving literals, under a layer like ace's: the layer no
// longer reaches grafana — which is why the catalogue moves influxdb first.
got := composedLikeAce(t, influxdbServing(false), aceLikeLayer())
for _, id := range []string{"grafana.influxdb-datasource", "grafana.bound-influxdb-api"} {
if strings.Contains(got[id], "made-up") {
t.Errorf("%s still carries the layer's value over a literal served key: %s", id, got[id])
}
}
if !strings.Contains(got["grafana.influxdb-datasource"], `dbName: default\n`) {
t.Errorf("the data source does not name the served literal: %s", got["grafana.influxdb-datasource"])
}
}
// influxdb's own runtime configuration takes the keys it names: org and bucket from a layer like
// ace's, and not the endpoint block the mesh reads itself, which today's form wrote there too.
func TestInfluxdbsConfigTakesItsOrgAndBucket(t *testing.T) {
got := composedLikeAce(t, influxdbServing(true), aceLikeLayer())["influxdb.runtime-config"]
var r map[string]any
if err := json.Unmarshal([]byte(got), &r); err != nil {
t.Fatal(err)
}
if want := "{\n \"bucket\": \"made-up-bucket\",\n \"org\": \"made-up-org\"\n}\n"; r["content"] != want {
t.Errorf("config.json: got %q, want %q", r["content"], want)
}
got = composedLikeAce(t, influxdbServing(true), nil)["influxdb.runtime-config"]
if err := json.Unmarshal([]byte(got), &r); err != nil {
t.Fatal(err)
}
if r["content"] != "{}\n" {
t.Errorf("a default reached the mergeable file: %q", r["content"])
}
}
+28 -1
View File
@@ -31,6 +31,28 @@ type SettingDeclaration struct {
Default any `json:"default"`
// Why this default: one sentence for whoever wonders whether to change it.
Why string `json:"why"`
// The config form's declaration (novox/hq ADR 0348 §4, config.go): none of these is said under
// `settings`, and neither Kind nor Why under `config`.
// Type is number, string or boolean; object or list only when Trusted.
Type string `json:"type,omitempty"`
// Description says what the value is, including its unit.
Description string `json:"description,omitempty"`
// Enum, Minimum, Maximum and Pattern are its validation: the values it may take, the bounds of a
// number, and the whole of a string matching the pattern.
Enum []any `json:"enum,omitempty"`
Minimum *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Pattern string `json:"pattern,omitempty"`
// Trusted is a value that decides what runs or who can reach what (ADR 0348 §8).
Trusted bool `json:"trusted,omitempty"`
// Predefined is the predefined key the value falls back to, of the same type (ADR 0348 §2, §7).
Predefined string `json:"predefined,omitempty"`
// Required is the operator's own value: it has no default (ADR 0348 §4).
Required bool `json:"required,omitempty"`
// Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5).
Runtime bool `json:"runtime,omitempty"`
}
// KindPreference is the kind of a setting that may have a default.
@@ -203,11 +225,16 @@ type SettingSource struct {
func Effective(m Manifest, layers []Layer) []SettingSource {
byKey := map[string]*SettingSource{}
for key, d := range m.Settings {
if d.Default == nil {
// A required config value has no default (ADR 0348 §4); a settings declaration always has one.
continue
}
byKey[key] = &SettingSource{Key: key, Value: d.Default, From: DefaultLayer, FromDefault: true,
Default: d.Default, HasDefault: true}
}
for _, layer := range layers {
for key, v := range layer.Values {
// As the layer is said, placement too: `settings` answers it as a person set it.
for key, v := range layer.Whole() {
s, ok := byKey[key]
if !ok {
s = &SettingSource{Key: key}
+1 -1
View File
@@ -427,7 +427,7 @@ func TestALeftOutModulesUnplaceableBackupLineCostsOnlyThatLine(t *testing.T) {
r := anAdoptedAnchor()
r.Modules = append(r.Modules, holder, media, placedBadly)
with := anchorRendering(false)
with.Settings["notes"] = []Layer{{From: "anchor", Values: map[string]any{PlacesSetting: "not a map"}}}
with.Settings["notes"] = []Layer{LayerOf("anchor", map[string]any{PlacesSetting: "not a map"})}
composed, err := r.Compose(with)
if err != nil {
t.Fatalf("an unplaceable line of a left-out module failed the machine: %v", err)
+41 -16
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"errors"
"fmt"
"regexp"
"sort"
@@ -47,10 +48,30 @@ func settingsUsed(content string) []string {
type UnsetSettingError struct {
Module, Setting string
said string
// where says what asks for it — "a file", "a service whose unit", "a directory <id> whose owner" — and
// setToday which of the module's keys a layer sets, for saying it again in the config form's words.
where, setToday string
}
func (e *UnsetSettingError) Error() string { return e.said }
// unsetInConfigWords is the refusal of a required config value nothing sets, in the words of a module written in the
// config form (novox/hq ADR 0348 §1, §4): `${config:<key>}` and the config verb, never `settings set`. Any other
// error, and any error of a module in the settings form, is returned as it was.
func unsetInConfigWords(err error, m Manifest) error {
var unset *UnsetSettingError
if err == nil || !m.DeclaresConfig() || !errors.As(err, &unset) {
return err
}
return &UnsetSettingError{Module: unset.Module, Setting: unset.Setting, where: unset.where,
setToday: unset.setToday, said: fmt.Sprintf(
"%s has %s that says ${config:%s}, and nothing sets the required config value %q for it — a "+
"required value is the operator's own and has no default (novox/hq ADR 0348 §4): set it with "+
"the config verb, for %s on this node, key %q%s",
unset.Module, unset.where, unset.Setting, unset.Setting, unset.Module, unset.Setting,
strings.ReplaceAll(unset.setToday, "no setting is set", "no config value is set"))}
}
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
//
// The last layer setting a key wins, which is the node's over the mesh's over the module's own
@@ -74,12 +95,13 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
for _, key := range settingsUsed(content) {
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+
"preference has a default in the definition (ADR 0262): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))}
return &UnsetSettingError{Module: module, Setting: key, where: "a file",
setToday: orNoSettings(layers), said: fmt.Sprintf(
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112), and only a "+
"preference has a default in the definition (ADR 0262): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))}
}
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
}
@@ -130,11 +152,12 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err
for _, key := range settingsUsed(unit) {
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
"%s has a service whose unit says ${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, key, key, module, key, orNoSettings(layers))}
return &UnsetSettingError{Module: module, Setting: key, where: "a service whose unit",
setToday: orNoSettings(layers), said: fmt.Sprintf(
"%s has a service whose unit says ${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, key, key, module, key, orNoSettings(layers))}
}
v := plainly(value)
if !unitPart.MatchString(v) {
@@ -229,11 +252,13 @@ func settingIntoOwner(resource map[string]any, layers []Layer, module string) er
for _, key := range settingsUsed(owner) {
value, set := settingValue(layers, key)
if !set {
return &UnsetSettingError{Module: module, Setting: key, said: fmt.Sprintf(
"%s has a directory %v whose owner says ${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))}
return &UnsetSettingError{Module: module, Setting: key,
where: fmt.Sprintf("a directory %v whose owner", resource["id"]), setToday: orNoSettings(layers),
said: fmt.Sprintf(
"%s has a directory %v whose owner says ${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)
+2 -1
View File
@@ -83,7 +83,8 @@ func TakenSettingsProblems(m Manifest) []string {
// Said only when none of the module's mergeable files names its keys: when one does, §2 already
// names every file that does not, and a file is not reported twice.
func WrittenSettingsProblems(m Manifest) []string {
if len(m.Settings) == 0 {
if len(m.Settings) == 0 || m.DeclaresConfig() {
// A module in the config form is held to this where it is read, in its own words (ConfigProblems).
return nil
}
var silent []string