assign: what it checks is the mesh, not the one machine
An assignment was verified by resolving the node it was made on. The verify that matters is resolution over all of them: a module offering a mesh-scoped provision stops offering it the moment its own node stops resolving, so an assignment could be reported as fine while it took that provision away from every consumer elsewhere. Those consumers were then told "nothing in this mesh provides it", naming as the remedy a module that was already assigned — a wrong answer about a machine nobody had touched. That is novox/hq 04-ISSUES/017's shape exactly: an action succeeds into a state its own verify rejects, and it does so because the action's own test is not the test the verify uses. 017's remedy was to make them the same test, and this makes them the same test. The assignment is still kept, and that is the other half of the decision. 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. So `assign` and `unassign` now name every OTHER machine that cannot be worked out as things stand, in the mesh's own words, beside whatever they already said about this one. It reports the state and never claims causation — 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. 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. This is what a four-node raise read as "bumping a module's version broke provider recognition". It was neither the version nor the provider: nothing in this codebase reads the version column, every lookup is keyed on the module name alone, and a test in the previous commit now says so. It was one machine's set of assignments, and nothing said so. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
)
|
||||
@@ -17,11 +19,25 @@ import (
|
||||
// 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 whether the whole set still resolves.
|
||||
// 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 it does not: it is what a person meant, and the refusal is
|
||||
// about the set rather than about this one. That is a decision, so it lives here rather than in
|
||||
// whichever surface asked.
|
||||
// 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) {
|
||||
if err := open.inventory.Assign(ctx, node, module); err != nil {
|
||||
return "", err
|
||||
@@ -29,8 +45,9 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
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.
|
||||
return said, err
|
||||
// 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
|
||||
@@ -43,15 +60,66 @@ func assign(ctx context.Context, open *stores, node, module string) (string, err
|
||||
said += "\n but " + catalogue.WrongMachine(u.Module, c, node)
|
||||
}
|
||||
}
|
||||
return said + fmt.Sprintf("\n run `push %s` to send it", node), nil
|
||||
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) {
|
||||
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), nil
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
)
|
||||
|
||||
// What an assignment says about the machines it was not about.
|
||||
|
||||
// **An assignment that blocks another machine says so.**
|
||||
//
|
||||
// The shape found on a four-node raise: a module offering a mesh-scoped provision stops offering it
|
||||
// the moment its own node stops resolving, so an assignment to the provider's machine silently took
|
||||
// a provision away from every consumer elsewhere. Those consumers were then told *nothing in this
|
||||
// mesh provides it*, naming as the remedy a module that was already assigned — which read, on the
|
||||
// way back, as a version bump breaking provider recognition. It was neither the version nor the
|
||||
// provider: it was one machine's set of assignments, and nothing said so.
|
||||
//
|
||||
// novox/hq 04-ISSUES/017 names the general shape — an action succeeding into a state its own verify
|
||||
// rejects, because the action's test is not the test the verify uses. `assign` tested one node; the
|
||||
// verify is resolution over all of them.
|
||||
func TestAnAssignmentThatBlocksAnotherMachineSaysWhichAndWhy(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// A provider on the hub and a consumer on the other machine, both resolving.
|
||||
register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"acme-ca": {"path": "/acme/directory"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"}})
|
||||
if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err := assign(ctx, open, "laptop", "route-proxy")
|
||||
if err != nil {
|
||||
t.Fatalf("a mesh that should resolve did not: %v\n%s", err, said)
|
||||
}
|
||||
if strings.Contains(said, "cannot be worked out") {
|
||||
t.Fatalf("a well mesh was reported as blocked:\n%s", said)
|
||||
}
|
||||
|
||||
// Now break the hub's own set, with nothing but the command a person has.
|
||||
one, two := rivals()
|
||||
register(t, open, one)
|
||||
register(t, open, two)
|
||||
if _, err := assign(ctx, open, "anchor", "rival-one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
said, err = assign(ctx, open, "anchor", "rival-two")
|
||||
if err == nil {
|
||||
t.Fatal("an assignment that makes its own node incoherent was not reported at all")
|
||||
}
|
||||
|
||||
// The node's own refusal is the error, as it always was. What is new is that the machines this
|
||||
// just took a provision away from are named in the same breath.
|
||||
if !strings.Contains(said, "laptop") {
|
||||
t.Fatalf("the machine this blocked is not named:\n%s\n\n%v", said, err)
|
||||
}
|
||||
if !strings.Contains(said, "acme-ca") {
|
||||
t.Fatalf("what laptop is now missing is not said:\n%s", said)
|
||||
}
|
||||
if !strings.Contains(said, "cannot be worked out as things stand") {
|
||||
t.Fatalf("the report does not say what state the mesh is in:\n%s", said)
|
||||
}
|
||||
// And the assignment is kept: it is what a person meant, and assignment is not an ordering.
|
||||
assigned, err := open.inventory.Assigned(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(assigned, "rival-two") {
|
||||
t.Fatalf("the assignment was not kept: %v", assigned)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer assigned before its provider is refused for itself and kept, because assignment is not
|
||||
// an ordering — refusing the first half of a pair would make the order somebody types two commands
|
||||
// in part of the mesh's rules.
|
||||
func TestAConsumerAssignedBeforeItsProviderIsStillAssigned(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"}})
|
||||
|
||||
said, err := assign(ctx, open, "laptop", "route-proxy")
|
||||
if err == nil {
|
||||
t.Fatalf("a consumer with nothing to consume resolved:\n%s", said)
|
||||
}
|
||||
assigned, err := open.inventory.Assigned(ctx, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(assigned, "route-proxy") {
|
||||
t.Fatalf("assignment became an ordering: %v", assigned)
|
||||
}
|
||||
}
|
||||
|
||||
// Unassigning is the ordinary way to stop providing something to another machine, and it reports
|
||||
// the same way for the same reason.
|
||||
func TestUnassigningAProviderNamesWhoIsNowBlocked(t *testing.T) {
|
||||
open := aMesh(t)
|
||||
ctx := t.Context()
|
||||
register(t, open, catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "acme-ca", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"acme-ca": {"path": "/acme/directory"}}})
|
||||
register(t, open, catalogue.Manifest{Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"}})
|
||||
if _, err := assign(ctx, open, "anchor", "step-ca"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assign(ctx, open, "laptop", "route-proxy"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
said, err := unassign(ctx, open, "anchor", "step-ca")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(said, "laptop") || !strings.Contains(said, "acme-ca") {
|
||||
t.Fatalf("taking the provider away said nothing about who was consuming it:\n%s", said)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(all []string, one string) bool {
|
||||
for _, s := range all {
|
||||
if s == one {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-control/internal/catalogue"
|
||||
"github.com/novox/mesh-control/internal/inventory"
|
||||
"github.com/novox/mesh-control/internal/licences"
|
||||
"github.com/novox/mesh-control/internal/overlay"
|
||||
)
|
||||
|
||||
// A mesh a command can be run against.
|
||||
//
|
||||
// The commands here were tested through the pieces they call and never through themselves, so
|
||||
// three faults that only exist where the pieces meet — an assignment reported as fine while it
|
||||
// blocked other machines, a read-shaped invocation that wrote, a JSON interface that stopped
|
||||
// emitting JSON — were invisible to every test in this package. This raises the real stores and
|
||||
// calls the real functions.
|
||||
|
||||
// aMesh is two placed, capable machines on a private network, with nothing assigned but the
|
||||
// network itself.
|
||||
//
|
||||
// `anchor` is the hub. That is not decoration: a mesh whose hub cannot be resolved has no private
|
||||
// network at all, which is how one machine's problem reaches every other.
|
||||
func aMesh(t *testing.T) *stores {
|
||||
t.Helper()
|
||||
inventory.ForTest(t) // raises the store, migrates it, and points the environment at it
|
||||
licences.ForTest(t) // planning reaches this one too, by name and never by connection
|
||||
|
||||
open, err := openStores(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(open.Close)
|
||||
for _, m := range provided {
|
||||
if err := open.inventory.Provide(t.Context(), m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for i, name := range []string{"anchor", "laptop"} {
|
||||
record, err := open.inventory.AddNode(t.Context(), name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.SetPlace(t.Context(), name, name+".example:51820", "here",
|
||||
name == "anchor", fmt.Sprintf("10.77.0.%d", i+1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reported, err := json.Marshal(map[string]any{"capabilities": []map[string]any{
|
||||
{"name": "container-runtime", "present": true},
|
||||
{"name": "wireguard", "present": true},
|
||||
{"name": "systemd", "present": true},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var profile map[string]any
|
||||
if err := json.Unmarshal(reported, &profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RecordProfile(t.Context(), record.ID, profile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RecordSealingKey(t.Context(), record.ID, aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.RecordOverlayKey(t.Context(), record.ID, aPublicKey(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := open.inventory.Assign(t.Context(), name, overlay.Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
// aPublicKey is a key a machine could have reported. Its private half is thrown away: nothing here
|
||||
// opens anything, it only needs the mesh to believe a machine has a key.
|
||||
func aPublicKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// register puts a manifest in the catalogue.
|
||||
func register(t *testing.T, open *stores, m catalogue.Manifest) {
|
||||
t.Helper()
|
||||
if err := open.inventory.RegisterModule(t.Context(), m, inventory.Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// rivals are two modules that cannot share a machine, which is the shortest way to make a node's
|
||||
// own set of assignments incoherent using nothing but commands a person has.
|
||||
func rivals() (catalogue.Manifest, catalogue.Manifest) {
|
||||
claim := []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}
|
||||
return catalogue.Manifest{Module: "rival-one", Version: "1", Claims: claim},
|
||||
catalogue.Manifest{Module: "rival-two", Version: "1", Claims: claim}
|
||||
}
|
||||
Reference in New Issue
Block a user