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.
145 lines
6.4 KiB
Go
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()
|
|
}
|