Take the catalogue's composition rules into the node-engine

ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk
carries no composition rule. The catalogue functions only the
node-engine's composition reaches (Resolve, Resolution's Compose,
Declaration, DataItems, LeftOut, Rules and the rest, the settings,
placeholder and template refusals, the rendering helpers) are copied
with their tests into internal/compose/resolution; a method of a
mesh-sdk shape becomes a function taking it first. The composition
calls its own copies, and the captured catalogue its tests read lives
here now, shared by mesh-compose's tests.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 19:15:55 +01:00
parent be0226b624
commit d68e241724
686 changed files with 35703 additions and 80 deletions
+517
View File
@@ -0,0 +1,517 @@
package resolution
import (
"bytes"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ApplySettings produces a resource's final content from the module's own and the layers over it.
//
// Layers are applied in order, so a later one beats an earlier one — the mesh's settings for a
// module first, then this node's. A node that differs from the rest is expressed by differing,
// rather than by repeating everything the rest already say.
func ApplySettings(resource map[string]any, layers []mcat.Layer) (map[string]any, error) {
// Today's form reaches a mergeable file with every key of the layer as it was said, placement too
// (novox/hq ADR 0326 §3, ADR 0348 clause 11: a module not yet moved keeps it), so a file composes
// byte for byte as it did while placement was kept in the layer.
layers = asSaid(layers)
how, _ := resource["merge"].(string)
taken, names := takenKeys(resource)
if how == "" {
if names {
// Refused by the module check too (TakenSettingsProblems); said here as well, for a
// definition stored by an older binary, rather than handed to the host, which knows no
// such field.
return nil, fmt.Errorf("%v says %s and does not merge: only a mergeable file takes "+
"settings by key (novox/hq ADR 0326)", resource["id"], mcat.TakesSettingsField)
}
// Not a mergeable file. Settings for it are ignored rather than silently doing nothing
// somewhere else — see UnusedSettings, which is what says so.
return resource, nil
}
if names {
// **The file takes the keys it names, and no other** (novox/hq ADR 0326, issue 168): a key
// for another file, for a `${setting:<key>}` or for the mesh itself does not land here.
layers = onlyTaken(layers, taken)
}
if how != mcat.MergeJSON {
return nil, fmt.Errorf(
"%v says it merges as %q, and this control plane knows how to merge %q",
resource["id"], how, mcat.MergeJSON)
}
content, _ := resource["content"].(string)
var base map[string]any
if strings.TrimSpace(content) == "" {
base = map[string]any{}
} else if err := json.Unmarshal([]byte(content), &base); err != nil {
return nil, fmt.Errorf("%v says it merges as JSON and its content is not JSON: %w",
resource["id"], err)
}
protected := map[string]bool{}
for _, k := range stringsOf(resource["protected"]) {
protected[k] = true
}
merged, err := settle(base, layers, protected, fmt.Sprint(resource["id"]))
if err != nil {
return nil, err
}
out := map[string]any{}
for k, v := range resource {
out[k] = v
}
// Keys sorted by the encoder, so the same settings always produce the same bytes. A file
// whose lines move for no reason makes every reconcile look like a change, and anything
// reflecting it would restart for ever.
rendered, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return nil, err
}
out["content"] = string(rendered) + "\n"
delete(out, "merge")
delete(out, "protected")
delete(out, mcat.TrustedField)
delete(out, mcat.TakesSettingsField)
return out, nil
}
// Settle fills what a provider serves from its settings. Exported because a served fact is settled
// where the mesh is walked rather than where a node is declared.
//
// **What a provider serves is its definition's; a layer replaces none of it** (novox/hq ADR 0348
// clause 11, issue 696). A served value is filled from the layers only where the definition asks,
// with `${setting:<key>}` (ADR 0155) — a mail provider's domain, an identity provider's issuer, a
// time-series store's org and bucket — and a layer that sets a key the provider serves as a literal
// changes nothing a consumer is told. Nor does a setting add a key (novox/hq 04-ISSUES/173): a
// setting made for one of the provider's files — a site name, a public address — is not part of the
// contract. Until issue 696, a layer replaced any served key it named; influxdb on ace was the one
// layer relying on it, and serves `${setting:org}` and `${setting:bucket}` since.
func Settle(base map[string]any, layers []mcat.Layer) (map[string]any, error) {
out, err := filledFromSettings(deepCopy(base), layers, "what is served")
if err != nil {
// Said as it was, untyped: what is served names no module here to type it by.
return nil, errors.New(err.Error())
}
return out, nil
}
// overridden lays settings over a contribution: a map whose keys are its contract. Only the keys the
// map already declares are touched; the rest of a layer is somebody else's business (a file's,
// another destination's) and is left to reach it there.
//
// A declared value may itself be the operator's, `${setting:<key>}` (ADR 0155), filled after the
// overrides (filledFromSettings).
func overridden(base map[string]any, layers []mcat.Layer, what string) (map[string]any, error) {
kept := make([]mcat.Layer, 0, len(layers))
for _, layer := range layers {
values := map[string]any{}
for key, value := range layer.Values {
if _, declared := base[key]; declared {
values[key] = value
}
}
kept = append(kept, mcat.Layer{From: layer.From, Values: values, Default: layer.Default})
}
merged, err := settle(base, kept, nil, what)
if err != nil {
return nil, err
}
return filledFromSettings(merged, layers, what)
}
// filledFromSettings fills each `${setting:<key>}` in a map's top-level strings from the layers,
// later beating earlier and a module's defaults lowest, and refuses one nothing sets by name — a
// literal placeholder handed to a consumer is a service configured against a string nobody meant.
// It fills merged in place and returns it.
func filledFromSettings(merged map[string]any, layers []mcat.Layer, what string) (map[string]any, error) {
for key, value := range merged {
s, ok := value.(string)
if !ok {
continue
}
for _, asked := range settingsUsed(s) {
v, set := settingValue(layers, asked)
if !set {
// Typed, its module and where left to the caller that knows them (Resolution.composed), so a module in
// the config form is refused in its own words (unsetInConfigWords; issue 1126, seed-8).
return nil, &UnsetSettingError{Setting: asked, field: key, setToday: orNoSettings(layers), said: fmt.Sprintf(
"%s says ${setting:%s} for %q, and nothing sets %q — an operator's value is the "+
"assignment's, never the definition's (novox/hq ADR 0112)%s",
what, asked, key, asked, orNoSettings(layers))}
}
s = strings.ReplaceAll(s, "${setting:"+asked+"}", plainly(v))
}
merged[key] = s
}
return merged, nil
}
// settle lays the layers over a module's own values, in order.
//
// Shared by a file's content and a module's contributions, because they are the same act: the
// module says what it means by default, and somebody says what it means here. A contribution that
// could not be settled would have to be edited to be reused anywhere else. The two differ in one
// respect, and the caller decides it: a file takes keys it did not declare (a setting may add to a
// configuration), a contribution does not (overridden). A served fact takes no layer at all; only
// its ${setting:<key>} is filled (Settle).
func settle(base map[string]any, layers []mcat.Layer, protected map[string]bool, what string) (
map[string]any, error) {
merged := deepCopy(base)
for _, layer := range layers {
if layer.Default {
// A module's own defaults fill ${setting:<key>} only (novox/hq ADR 0262). A mergeable
// file's content already is its defaults, and a contribution or served fact declares its
// own; laid on here, a default would reach every mergeable file of its module (issue 168).
continue
}
for key, value := range layer.Values {
if key == mcat.PortsSetting {
// Where the machine puts a port is the mesh's to apply, not a value for a file or
// for what a consumer is told (novox/hq ADR 0100); it reaches both as the port.
continue
}
if protected[key] {
// The module said it must own this one. Refused rather than ignored: a setting
// that is quietly dropped is somebody believing they changed something.
return nil, fmt.Errorf(
"%s sets %q on %v, and that module keeps %q for itself — it is not settable",
layer.From, key, what, key)
}
// **No layer replaces a secret the vault fills, at any depth** (novox/hq issue 610): a
// mergeable file's `${secret:<name>}` is the vault's value, and a layer that reached it —
// `server.secret_key` under a `server` the file takes — would put a value the operator or an
// agent chose where the vault's belongs. Judged against the file's own content, so it holds
// whatever a module's `protected` list says.
if at := secretReplacedBy(base[key], value, key); at != "" {
return nil, fmt.Errorf(
"%s sets %q on %v, which the vault fills (${secret:…}); a secret comes from the vault, "+
"never from a setting (novox/hq issues 342, 610)", layer.From, at, what)
}
merged[key] = mergeValue(merged[key], value)
}
}
return merged, nil
}
// mergeValue combines one value with the one over it.
//
// Two objects merge key by key, so setting one field of a nested block does not delete its
// siblings. Anything else is replaced whole: a list that merged element-wise could neither be
// shortened nor reordered, and there is no correct guess about which element is "the same one".
func mergeValue(under, over any) any {
underMap, isUnderMap := under.(map[string]any)
overMap, isOverMap := over.(map[string]any)
if !isUnderMap || !isOverMap {
return over
}
merged := deepCopy(underMap)
for k, v := range overMap {
merged[k] = mergeValue(merged[k], v)
}
return merged
}
func deepCopy(in map[string]any) map[string]any {
out := map[string]any{}
for k, v := range in {
if nested, ok := v.(map[string]any); ok {
out[k] = deepCopy(nested)
continue
}
out[k] = v
}
return out
}
// settingsHoldOneLine refuses a line break, a carriage return, any other line end (lineEnds) or a NUL in any
// string of any setting, for every
// module, at any depth, keys as well as values, in an object or a list (novox/hq issue 339). A value is
// substituted into env and configuration files the node-engine writes as root — an app's env file, a logind
// drop-in — and a line break there is a line of the caller's own: a directive, an assignment, a section. A NUL
// ends a string early wherever C reads it. Judged where a layer is kept and again where it is composed, so a
// layer that holds one, however it got into the store, is said with its key. What must hold lines is a file
// of the module's own, never a setting — with one exception, the certificate authority's root as certificates
// (certificates), because that is how step-ca serves it.
func settingsHoldOneLine(module string, layers []mcat.Layer) error {
for _, layer := range layers {
for _, key := range sortedKeysAny(layer.Values) {
if module == rootModule && key == rootSetting {
if text, ok := layer.Values[key].(string); ok && certificates(text) {
continue
}
}
if at := lineBreakIn(layer.Values[key], key); at != "" {
return fmt.Errorf("%s: the setting %s in %q holds a line break, a carriage return, another line end or a NUL, which a "+
"file it is written into would read as a line of its own; a setting is one line (novox/hq "+
"issue 339)", module, at, layer.From)
}
}
}
return nil
}
// lineEnds are every character a reader may take as the end of a line: \n and \r, vertical tab and form feed,
// NEL, and the Unicode line and paragraph separators; and NUL, which ends a string early wherever C reads it.
const lineEnds = "\n\r\v\f\x00\u0085\u2028\u2029"
const rootModule = "step-ca"
const rootSetting = "root"
// certificates says whether a value is one or more PEM CERTIFICATE blocks and nothing else: each decodes with
// encoding/pem, carries no headers, and parses with x509.ParseCertificate; nothing but a final line break
// surrounds them, and every line ends with \n alone.
func certificates(v string) bool {
if strings.ContainsAny(v, "\r\v\f\x00\u0085\u2028\u2029") {
return false
}
rest := []byte(v)
found := 0
for len(rest) > 0 {
if !bytes.HasPrefix(rest, []byte("-----BEGIN ")) {
return false
}
block, after := pem.Decode(rest)
if block == nil || block.Type != "CERTIFICATE" || len(block.Headers) > 0 {
return false
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return false
}
found++
rest = after
}
return found > 0
}
// lineBreakIn is where the first string under v holding \n, \r or NUL is, or "".
func lineBreakIn(v any, at string) string {
if strings.ContainsAny(at, lineEnds) {
return strconv.Quote(at)
}
switch t := v.(type) {
case string:
if strings.ContainsAny(t, lineEnds) {
return at
}
case map[string]any:
for _, k := range sortedKeysAny(t) {
if found := lineBreakIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, e := range t {
if found := lineBreakIn(e, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}
func sortedKeysAny(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func stringsOf(v any) []string {
raw, ok := v.([]any)
if !ok {
return nil
}
var out []string
for _, item := range raw {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
// KeptNetworks reads which found networks each of a module's containers keeps, by container id.
//
// Refused from a mesh-wide layer — a found network is a fact about one machine — for an id the
// module declares no container under, for a name that is not a network's, and on a machine that
// is not adopted: the setting exists so neighbours the mesh has not taken yet keep reaching the
// container, and a converged machine has no such neighbours.
func KeptNetworks(m mcat.Manifest, layers []mcat.Layer, adopted bool) (map[string][]string, error) {
containers := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "container" {
containers[fmt.Sprint(r["id"])] = true
}
}
out := map[string][]string{}
for _, layer := range layers {
raw, ok := layer.Placement[mcat.NetworksSetting]
if !ok {
continue
}
if layer.From == mcat.MeshWideLayer {
return nil, fmt.Errorf("%s: %s is given per node — a found network is a fact about one "+
"machine; set it with --node", m.Module, mcat.NetworksSetting)
}
if !adopted {
return nil, fmt.Errorf("%s: %s keeps a found network for neighbours the mesh has not taken "+
"yet, and %s is converged — nothing on it is found; clear the setting", m.Module, mcat.NetworksSetting, layer.From)
}
blocks, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { container: [network, …] } map, and %q set it to "+
"something else", m.Module, mcat.NetworksSetting, layer.From)
}
for id, body := range blocks {
if !containers[id] {
return nil, fmt.Errorf("%s: %s names the container %q, which it does not declare — "+
"the setting reaches nothing; it declares %s", m.Module, mcat.NetworksSetting, id,
orNothing(sortedKeys(containers)))
}
names := stringsOf(body)
if len(names) == 0 {
return nil, fmt.Errorf("%s: %s for %q is a list of network names, and %q set it to %v",
m.Module, mcat.NetworksSetting, id, layer.From, body)
}
for _, n := range names {
if !networkName.MatchString(n) {
return nil, fmt.Errorf("%s: %s for %q names %q, which is not a network name",
m.Module, mcat.NetworksSetting, id, n)
}
}
sort.Strings(names)
out[id] = names
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// networkName is what a container runtime accepts as a network's name.
var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
// JudgeSettings composes a module's settings against its definition and refuses the first thing
// that cannot work, naming the module, the layer and the key (novox/hq ADR 0163, rule 6).
//
// **The same judgement where a setting is stored and where a machine is declared.** Stored, a
// setting that cannot compose is refused before it is kept; composed later, a definition that has
// moved under a stored setting leaves that module out of the machine's declaration rather than
// the machine without one. Every reader of settings runs here: a port given, an exposure, a reach,
// an endpoint, a placement, an access, a kept network, a mergeable file's keys and a file's
// `${setting:…}`. A key that reaches nothing is not here: it cannot break a composition, so it is
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
// and never costs a module its place.
func JudgeSettings(m mcat.Manifest, layers []mcat.Layer, adopted bool) error {
// Placement is judged for line ends as it always was: a path is written into files as well.
if err := settingsHoldOneLine(m.Module, asSaid(layers)); err != nil {
return err
}
// Which keys each mergeable file takes is the definition's to say right (novox/hq ADR 0326);
// judged here too for a definition stored by an older binary.
if problems := takenSettingsProblems(m); len(problems) > 0 {
return fmt.Errorf("%s", problems[0])
}
// With no layers too: a definition may ask for a setting nobody made — an access placed by
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
if _, err := GivenPorts(m, layers); err != nil {
return err
}
if _, err := Reaches(m, layers); err != nil {
return err
}
if _, err := mcat.Endpoints(m, layers); err != nil {
return err
}
if _, err := mcat.Places(m, layers); err != nil {
return err
}
if _, _, err := accessesFor(m, layers); err != nil {
return err
}
if _, err := KeptNetworks(m, layers, adopted); err != nil {
return err
}
for _, r := range m.Resources {
settled, err := ApplySettings(r, layers)
if err != nil {
return err
}
copied := map[string]any{}
for k, v := range settled {
copied[k] = v
}
if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, layers), m.Module), m); err != nil {
return err
}
}
return nil
}
// secretReplacedBy is the path in a mergeable file's own content that a layer's value would replace while the
// vault fills it — a string holding `${secret:` — or "" when the value leaves every such string as it is
// (novox/hq issue 610). Maps merge key by key (mergeValue), so only the keys the value names are judged; any
// other value replaces the whole of what it lands on.
func secretReplacedBy(base, value any, at string) string {
if baseMap, isMap := base.(map[string]any); isMap {
if valueMap, also := value.(map[string]any); also {
keys := make([]string, 0, len(valueMap))
for k := range valueMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if found := secretReplacedBy(baseMap[k], valueMap[k], at+"."+k); found != "" {
return found
}
}
return ""
}
}
return secretIn(base, at)
}
// secretIn is the first path under v holding a vault placeholder, or "".
func secretIn(v any, at string) string {
switch t := v.(type) {
case string:
if strings.Contains(t, "${secret:") {
return at
}
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if found := secretIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, item := range t {
if found := secretIn(item, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}