Feature 57 (config and provisioning): release mesh-controller, the reviewed release branch merged with main, on top of main

This commit is contained in:
2026-10-16 08:28:38 +00:00
parent d70bb1f388
commit d984816844
259 changed files with 34899 additions and 353 deletions
+372
View File
@@ -0,0 +1,372 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// **`settings` answers by forwarding to `config`** (novox/hq ADR 0348 §1, design 57 K5, issue 664). Once the move of
// config has run, a module in the config form keeps its config values in mesh-config (inventory.ReadsConfig), so the
// settings verb reads and writes them there: `show` lists what `config list` lists, `set` sets each value as the
// module's own on the node and `clear` clears them, and `propose` proposes each through mesh-config. Placement stays
// the controller's and is read and written beside the assignment as before (ADR 0348 §11). A module still in the
// settings form, and every module before the move, keeps today's verb unchanged.
// configAsk asks mesh-config's verb in the controller's own name, so mesh-config takes a value as the controller
// carries it (ADR 0348 §8); a test replaces it.
var configAsk = func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
var result json.RawMessage
err := onTheBus(func(conn *nats.Conn) error {
answer, err := link.AskMeshSeatToolAs(ctx, conn, broker.ControllerName, "mesh-config", "config", args, 30*time.Second)
if err != nil {
return err
}
if answer.Error != "" {
return errors.New(answer.Error)
}
result = answer.Result
return nil
})
return result, err
}
// configListed is what `config list` answers, as the settings verb reads it.
type configListed struct {
Resolved []resolvedEntry `json:"resolved"`
Set []struct {
Level configLevel `json:"level"`
Key string `json:"key"`
Value json.RawMessage `json:"value"`
By string `json:"by"`
} `json:"set"`
}
func listConfig(ctx context.Context, module, node string) (configListed, error) {
args := map[string]any{"action": "list", "module": module}
if node != "" {
args["node"] = node
}
raw, err := configAsk(ctx, args)
if err != nil {
return configListed{}, fmt.Errorf("mesh-config does not answer %s's config values: %w", module, err)
}
var l configListed
if err := json.Unmarshal(raw, &l); err != nil {
return configListed{}, fmt.Errorf("mesh-config's listing cannot be read: %w", err)
}
return l, nil
}
// ownValues is the module's own values on the node, as mesh-config holds them; none for the whole mesh, which has no
// module value (ADR 0348 §3).
func (l configListed) ownValues(module, node string) map[string]any {
out := map[string]any{}
if node == "" {
return out
}
for _, s := range l.Set {
if s.Level.Level == "module" && s.Level.Node == node && s.Level.Module == module {
var v any
if json.Unmarshal(s.Value, &v) == nil {
out[s.Key] = v
}
}
}
return out
}
// forwardSettings answers a settings action for a module whose config values are mesh-config's, and says whether it
// did: false leaves the action to today's verb.
func forwardSettings(ctx context.Context, inv *inventory.Inventory, action, module, node string, positionals []string,
replace bool) (bool, error) {
if module == "" {
return false, nil
}
reads, err := inv.ReadsConfig(ctx, module)
if err != nil || !reads {
return false, err
}
where := whereOf(node)
switch action {
case "show":
listed, err := listConfig(ctx, module, node)
if err != nil {
return true, err
}
layer, _, err := inv.Layer(ctx, node, module)
if err != nil {
return true, err
}
if layer == nil {
layer = map[string]any{}
}
for k, v := range listed.ownValues(module, node) {
layer[k] = v
}
if len(layer) == 0 {
fmt.Printf("%s on %s: no layer — the module's definition says\n", module, where)
} else {
shown, err := json.MarshalIndent(layer, "", " ")
if err != nil {
return true, err
}
fmt.Println(string(shown))
}
fmt.Printf(" its config values are mesh-config's since the move of config (issue 664): mesh-config.config\n")
fmt.Printf("%s on %s, every value and where it comes from:\n", module, where)
for _, r := range listed.Resolved {
value := string(r.Value)
if value == "" {
value = "unset"
}
on := ""
if node == "" {
on = " on " + r.Node
}
fmt.Printf(" %s%s = %s (%s)\n", r.Key, on, value, r.Source)
}
return true, nil
case "set":
var raw []byte
if strings.HasPrefix(strings.TrimSpace(positionals[1]), "{") {
raw = []byte(positionals[1])
} else {
return true, errors.New("the settings of a module whose config values are mesh-config's are given as " +
"{…}, never a file; or set each with mesh-config.config")
}
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return true, fmt.Errorf("%s is not a settings object: %w", positionals[1], err)
}
return true, forwardSet(ctx, inv, module, node, values, replace)
case "clear":
listed, err := listConfig(ctx, module, node)
if err != nil {
return true, err
}
own := listed.ownValues(module, node)
layer, _, err := inv.Layer(ctx, node, module)
if err != nil {
return true, err
}
before := map[string]any{}
for k, v := range layer {
before[k] = v
}
for k, v := range own {
before[k] = v
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, module, where); err != nil {
return true, err
}
if err := refuseTrustedThroughAVerb(ctx, inv, module, where, movedKeys(own)); err != nil {
return true, err
}
for _, k := range movedKeys(own) {
if _, err := configAsk(ctx, map[string]any{"action": "clear", "module": module, "node": node, "key": k,
"why": "settings clear " + module + " on " + where, "by": setByWords()}); err != nil {
return true, fmt.Errorf("%s of %s on %s was not cleared: %w", k, module, where, err)
}
}
if len(layer) > 0 {
if err := inv.ClearSettingsBy(ctx, node, module, setByWords()); err != nil {
return true, err
}
}
fmt.Printf("%s on %s is back to what the module says\n", module, where)
return true, nil
}
return false, nil
}
// forwardSet sets a layer of a module whose config values are mesh-config's: its placement beside the assignment
// as before, each config value as the module's own on the node, and each it no longer names cleared.
func forwardSet(ctx context.Context, inv *inventory.Inventory, module, node string, values map[string]any,
replace bool) error {
where := whereOf(node)
config, placement := catalogue.SplitPlacement(values)
listed, err := listConfig(ctx, module, node)
if err != nil {
return err
}
own := listed.ownValues(module, node)
layer, _, err := inv.Layer(ctx, node, module)
if err != nil {
return err
}
before := map[string]any{}
for k, v := range layer {
before[k] = v
}
for k, v := range own {
before[k] = v
}
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, values, module, where); err != nil {
return err
}
added, changed, removed := settingsChange(before, values)
if len(removed) > 0 && !replace {
return fmt.Errorf("%s on %s: this layer would no longer set %s. A layer is replaced whole; read it with "+
"`settings show %s%s` and include what should stay, or add --replace if the removal is meant (novox/hq "+
"ADR 0217). Nothing was changed", module, where, strings.Join(removed, ", "), module, nodeFlag(node))
}
var toSet, toClear []string
for _, k := range movedKeys(config) {
if was, had := own[k]; !had || !sameJSON(valueJSON(was), config[k]) {
toSet = append(toSet, k)
}
}
for _, k := range movedKeys(own) {
if _, kept := config[k]; !kept {
toClear = append(toClear, k)
}
}
if node == "" && len(toSet)+len(toClear) > 0 {
return fmt.Errorf("%s has no own value for the whole mesh (novox/hq ADR 0348 §3): name the node, or set mesh "+
"config for the predefined key a value falls back to with mesh-config.config. Nothing was changed", module)
}
if err := refuseTrustedThroughAVerb(ctx, inv, module, where, append(append([]string{}, toSet...), toClear...)); err != nil {
return err
}
if placement == nil {
placement = map[string]any{}
}
if _, placed := catalogue.SplitPlacement(layer); !sameLayer(placed, placement) {
if err := inv.SetSettingsBy(ctx, node, module, placement, setByWords()); err != nil {
return err
}
}
for _, k := range toSet {
if _, err := configAsk(ctx, map[string]any{"action": "set", "module": module, "node": node, "key": k,
"value": config[k], "why": "settings set " + module + " on " + where, "by": setByWords()}); err != nil {
return fmt.Errorf("%s of %s on %s was not set: %w", k, module, where, err)
}
}
for _, k := range toClear {
if _, err := configAsk(ctx, map[string]any{"action": "clear", "module": module, "node": node, "key": k,
"why": "settings set " + module + " on " + where + " --replace", "by": setByWords()}); err != nil {
return fmt.Errorf("%s of %s on %s was not cleared: %w", k, module, where, err)
}
}
fmt.Printf("%s on %s:\n", module, where)
if len(added)+len(changed)+len(removed) == 0 {
fmt.Println(" nothing changed")
}
for _, p := range added {
fmt.Printf(" + %s\n", p)
}
for _, p := range changed {
fmt.Printf(" ~ %s\n", p)
}
for _, p := range removed {
fmt.Printf(" - %s\n", p)
}
fmt.Println(" its config values are mesh-config's (issue 664): each was set there, with its history; the module " +
"takes them when its files are filled again")
return nil
}
func valueJSON(v any) json.RawMessage {
raw, _ := json.Marshal(v)
return raw
}
// refuseTrustedThroughAVerb refuses, through a verb, a trusted config value (ADR 0348 §8): mesh-config takes the
// controller's word for one as given at its terminal, and whoever may call a verb includes agents.
func refuseTrustedThroughAVerb(ctx context.Context, inv *inventory.Inventory, module, where string, keys []string) error {
verb, through := throughAVerb()
if !through || len(keys) == 0 {
return nil
}
shelf, err := inv.Catalogue(ctx)
if err != nil {
return fmt.Errorf("which config values of %s are trusted cannot be read, so nothing was changed: %w", module, err)
}
for _, k := range keys {
if shelf[module].Settings[k].Trusted {
return fmt.Errorf("%s of %s on %s is trusted (novox/hq ADR 0348 §8): it is set at the controller's terminal "+
"or on the operator's Approve, never through a verb alone (this line came through %q). Propose it with "+
"mesh-config.config, action propose. Nothing was changed", k, module, where, verb)
}
}
return nil
}
// forwardPropose proposes, through mesh-config, each config value of a layer proposed for a module whose config
// values are mesh-config's (ADR 0348 §8): mesh-config asks the operator under its own name, one ask per value. A
// proposal naming placement too is refused, so the operator is never asked half of it; placement alone is proposed
// as before. Says whether it answered.
func forwardPropose(ctx context.Context, inv *inventory.Inventory, module, node, valuesArg string, clear bool) (bool, error) {
reads, err := inv.ReadsConfig(ctx, module)
if err != nil || !reads {
return false, err
}
where := whereOf(node)
if node == "" {
return true, fmt.Errorf("%s has no own value for the whole mesh (novox/hq ADR 0348 §3): name the node, or "+
"propose mesh config for the predefined key a value falls back to with mesh-config.config. Nothing was "+
"proposed", module)
}
proposals := map[string]any{}
var clears []string
if clear {
listed, err := listConfig(ctx, module, node)
if err != nil {
return true, err
}
layer, _, err := inv.Layer(ctx, node, module)
if err != nil {
return true, err
}
if len(layer) > 0 {
return true, fmt.Errorf("%s on %s keeps placement beside its assignment (%s): propose clearing it apart "+
"from its config values. Nothing was proposed", module, where, strings.Join(movedKeys(layer), ", "))
}
clears = movedKeys(listed.ownValues(module, node))
} else {
var values map[string]any
if err := json.Unmarshal([]byte(valuesArg), &values); err != nil {
return true, fmt.Errorf("%s is not a settings object: %w", valuesArg, err)
}
config, placement := catalogue.SplitPlacement(values)
if len(placement) > 0 {
if len(config) > 0 {
return true, fmt.Errorf("%s names placement (%s) and config values: propose them apart, the config "+
"values through mesh-config. Nothing was proposed", module, strings.Join(movedKeys(placement), ", "))
}
return false, nil
}
proposals = config
}
for _, k := range movedKeys(proposals) {
answer, err := configAsk(ctx, map[string]any{"action": "propose", "module": module, "node": node, "key": k,
"value": proposals[k], "why": "settings propose " + module + " on " + where})
if err != nil {
return true, fmt.Errorf("%s of %s on %s was not proposed: %w", k, module, where, err)
}
fmt.Printf("%s of %s on %s, proposed through mesh-config: %s\n", k, module, where, answer)
}
for _, k := range clears {
answer, err := configAsk(ctx, map[string]any{"action": "propose", "module": module, "node": node, "key": k,
"clear": true, "why": "settings propose " + module + " on " + where + " --clear"})
if err != nil {
return true, fmt.Errorf("clearing %s of %s on %s was not proposed: %w", k, module, where, err)
}
fmt.Printf("clearing %s of %s on %s, proposed through mesh-config: %s\n", k, module, where, answer)
}
return true, nil
}