Files
mesh-controller/cmd/mesh-controller/acts.go
T
jschoubben 50734095b8 A repeat assignment says nothing changed (ADR 0115)
One assignment of a module per node is now the rule, not a limitation —
the operator dropped the multi-assignment requirement, and the schema's
(node, module) key has been the decision since migration 0005. What
changed: Assign reports whether the assignment was new, and the command
says 'already runs — one node runs one of each (ADR 0115); nothing
changed' instead of printing 'is assigned' for a no-op, which read as
an action that happened. Idempotence stays: a repeat is exit 0, because
a script stating what is already true is not wrong.
2026-09-26 19:02:35 +02:00

145 lines
6.4 KiB
Go

package main
import (
"context"
"fmt"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The things the mesh can be asked to do, separated from how it was asked.
//
// **A surface is an adapter with no decisions in it** (novox/hq ADR 0035). The command line and
// the command API call the same functions here, so an assignment refused at one is refused at the
// other for the same reason and in the same words. The moment a surface can accept something
// another would reject, the mesh has two answers to one question and people learn which to trust.
//
// Each returns what happened as text a person can read and a caller can pass on. Neither surface
// composes its own explanation, because two explanations of one refusal drift.
// assign puts a module on a node, and says at once what in the mesh no longer works out.
//
// The assignment is kept even when the node does not resolve: it is what a person meant, and
// assignment is not an ordering. A consumer assigned before its provider does not resolve for as
// long as it takes to assign the provider, and refusing the first half of a pair would make the
// order somebody types two commands in part of the mesh's rules.
//
// **What is checked is the mesh, not the one machine** — because that is what the assignment
// changes. novox/hq 04-ISSUES/017 names the shape: an action can succeed into a state its own
// verify rejects, and it happens when the action's test is not the test the verify uses. Here the
// action tested one node and the verify is resolution over all of them, so an assignment could be
// reported as fine while it took a provision away from every other machine — a module offering a
// mesh-scoped provision stops offering it the moment its own node stops resolving, and every
// consumer elsewhere is then told *nothing in this mesh provides it*, with a remedy that names a
// module already assigned. That was found on a four-node raise and read as a version bump breaking
// provider recognition; it was neither the version nor the provider.
//
// It costs a resolution per machine. Assignment is a person typing a command, and being told which
// machines this just blocked is worth more than the milliseconds.
func assign(ctx context.Context, open *stores, node, module string) (string, error) {
// Held while it is recorded, so it cannot land between a converge's preview and its flip and
// be taken without ever having been previewed (novox/hq ADR 0100).
ctx, release, err := holdNodes(ctx, open, []string{node})
if err != nil {
return "", err
}
defer release()
fresh, err := open.inventory.Assign(ctx, node, module)
if err != nil {
return "", err
}
if !fresh {
// Nothing changed, and saying "is assigned" would read as an action. One node runs one
// of each — the module's name is the assignment's identity (novox/hq ADR 0115).
return fmt.Sprintf("%s already runs %s — one node runs one of each (ADR 0115); nothing changed",
node, module), nil
}
said := fmt.Sprintf("%s is assigned %s", node, module)
plan, _, err := planFor(ctx, open, node)
if err != nil {
// Kept, and still refused. Both halves are the answer, and the rest of the mesh is still
// worth reporting: this machine's refusal is rarely the only consequence.
return said + blockedElsewhere(ctx, open, node), err
}
// Kept, and cannot be hosted here. Said at once rather than discovered at push: a module whose
// capability the machine lacks is on the wrong machine, and the assignment records what a person
// meant while this line says it will not run until it moves. The rest of the node still pushes.
for _, u := range plan.Unhostable {
if u.Module != module {
continue
}
for _, c := range u.Missing {
said += "\n but " + catalogue.WrongMachine(u.Module, c, node)
}
}
return said + fmt.Sprintf("\n run `push %s` to send it", node) +
blockedElsewhere(ctx, open, node), nil
}
// unassign takes a module off a node. What it leaves behind is the host's business: a directory
// holding anything the mesh did not put there is kept (novox/hq ADR 0030).
//
// It reports the rest of the mesh for the same reason assign does, and more sharply: taking a
// module off one machine is the ordinary way to stop providing something to another, and nothing
// about the command's own output would ever have said so.
func unassign(ctx context.Context, open *stores, node, module string) (string, error) {
ctx, release, err := holdNodes(ctx, open, []string{node})
if err != nil {
return "", err
}
defer release()
if err := open.inventory.Unassign(ctx, node, module); err != nil {
return "", err
}
return fmt.Sprintf("%s no longer runs %s — run `push %s` to make it so",
node, module, node) + blockedElsewhere(ctx, open, node), nil
}
// blockedElsewhere is every OTHER machine that cannot be worked out as things now stand.
//
// **The state, not the cause.** Saying "this assignment broke laptop" would mean resolving the
// whole mesh twice and would still be a guess about which of several changes did it; saying
// "laptop cannot be worked out, and here is what it says" is true, is what somebody has to fix,
// and cannot mislead. The machine that was just changed is left out because its own refusal is
// already the answer beside this one.
//
// Nothing here can fail the act it reports on. A mesh that cannot be read is worth saying and is
// not a reason to claim the assignment did not happen — it did.
func blockedElsewhere(ctx context.Context, open *stores, except string) string {
nodes, err := open.inventory.Nodes(ctx)
if err != nil {
return "\n\nThe rest of the mesh could not be checked: " + err.Error()
}
blocked := map[string]string{}
for _, n := range nodes {
if n.Name == except {
continue
}
if _, _, err := planFor(ctx, open, n.Name); err != nil {
blocked[n.Name] = err.Error()
}
}
if len(blocked) == 0 {
return ""
}
names := make([]string, 0, len(blocked))
for name := range blocked {
names = append(names, name)
}
sort.Strings(names)
var out strings.Builder
fmt.Fprintf(&out, "\n\nAND %d other machine(s) cannot be worked out as things stand, so "+
"nothing will be sent to them:\n", len(names))
for _, name := range names {
fmt.Fprintf(&out, " %s\n", name)
for _, line := range strings.Split(strings.TrimRight(blocked[name], "\n"), "\n") {
fmt.Fprintf(&out, " %s\n", strings.TrimSpace(line))
}
}
out.WriteString("\nThis may or may not be what just changed — it is what is true now.")
return out.String()
}