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
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user