Compare commits

...
3 Commits
Author SHA1 Message Date
jochen 0350b4e222 Judge a mesh of one by the inventory, not by who reads as on the network (issue 701, review 275)
mesh/merge-gate out of date: judged against trunk 535c7641ac41, the trunk is now c975dbf7b053; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 535c7641ac41, the trunk is now c975dbf7b053; judged again on a recheck or when it is merged
mesh/delivery checking
The network reading leaves out a machine whose assignments cannot be read
or whose modules do not resolve, so a bad reading could make a mesh of
several look like one and send its machines to loopback. Count the nodes
the inventory knows, and every recorded holding and assigned claimant of
the resolver seat, before any address filter.
2026-10-14 11:06:56 +02:00
jochen d53fea31f0 Compose a lone node off the network while nothing answers its name (issue 701)
mesh/merge-gate out of date: judged against trunk 04730b54a8f2, the trunk is now 535c7641ac41; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 04730b54a8f2, the trunk is now 535c7641ac41; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
The first change kept the base references on loopback, and the build agent
then failed to publish: its registry is its same-node binding, composed at
the node's own internal name. While a mesh of one has no resolver, compose
that node without its name, so what it binds to on itself is loopback, as
before the network.
2026-10-14 08:58:00 +02:00
jochen eb99284890 Hand a mesh of one its store by loopback until a resolver answers its name (issue 701)
mesh/merge-gate head eb99284890cc merged into trunk 3479a35462b2 — pass: builds mesh-controller → novox; no bus step; every machine composes with the chang…
mesh/repo-check head eb99284890cc merged into trunk 3479a35462b2 — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Genesis places the private network at step 21, before anything holds the
mesh's resolver seat, and from then the store was named anchor.internal.
Nothing answered that name, so the filter's build at step 22 could not
pull its base. While the store's node is the only one on the network and
no resolver holds the seat, keep answering it loopback, as before the
network; a mesh of several, or one with a resolver, is answered as before.
2026-10-14 08:32:15 +02:00
4 changed files with 233 additions and 1 deletions
+157
View File
@@ -10,6 +10,7 @@ import (
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/licences"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/overlay"
)
// An address is read from the node's settings where it is used, never recorded with a port
@@ -350,3 +351,159 @@ func TestOnANodeWithNoNetworkTheStoreIsReachedByLoopback(t *testing.T) {
t.Fatalf("a builder beside the store is handed the base %q", got)
}
}
// **A mesh of one on its network, with no resolver yet, still reaches its store by loopback** (novox/hq issue
// 701). Genesis places the network at its step 21, before anything holds the mesh's resolver seat; the
// filter's build at step 22 was handed anchor.internal:5000, which nothing answered, and stopped. Once a
// resolver holds the seat the store is named by the network's name; a mesh of several is answered as before
// (TestTheRegistryTrustAndEveryImageFollowThePortTheNodeGaveTheStore).
func TestAMeshOfOneIsHandedNoNameUntilAResolverAnswersIt(t *testing.T) {
open := aLoneNode(t)
ctx := t.Context()
// On the private network, as genesis's network step leaves it: placed as the hub, able to run the
// overlay, its key reported, the overlay assigned.
if err := open.inventory.SetPlace(ctx, "anchor", "anchor.example:51820", "here", true, "10.77.0.1"); err != nil {
t.Fatal(err)
}
nodes, err := open.inventory.Nodes(ctx)
if err != nil || len(nodes) != 1 {
t.Fatalf("nodes: %v %v", nodes, err)
}
if err := open.inventory.RecordProfile(ctx, nodes[0].ID, map[string]any{"capabilities": []any{
map[string]any{"name": "container-runtime", "present": true},
map[string]any{"name": "wireguard", "present": true},
map[string]any{"name": "systemd", "present": true}}}); err != nil {
t.Fatal(err)
}
if err := open.inventory.RecordOverlayKey(ctx, nodes[0].ID, aPublicKey(t)); err != nil {
t.Fatal(err)
}
if _, err := open.inventory.Assign(ctx, "anchor", overlay.Name); err != nil {
t.Fatal(err)
}
register(t, open, aStore())
register(t, open, catalogue.Manifest{Module: "builder", Version: "1",
Requires: []string{catalogue.ArtifactStoreProvision},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-builder",
"image": "registry.example/mesh-builder@" + aDigest},
// Where the build agent publishes, as its manifest says it.
{"id": "env", "type": "file", "path": "/etc/mesh-builder.env",
"content": "MESH_REGISTRY=${bound:" + catalogue.ArtifactStoreProvision + ":at}:${bound:" +
catalogue.ArtifactStoreProvision + ":port}\n"}}})
if err := open.inventory.RecordBuild(ctx, inventory.Build{
ID: "b1", Repository: "r", Module: "mesh-tools", Commit: "abc",
Made: []inventory.Artifact{{Name: "build", Kind: "image",
Reference: catalogue.ArtifactStoreScheme + "mesh-tools/build@" + aDigest}},
}); err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "mesh-tools", Version: "1"})
for _, module := range []string{"distribution", "builder"} {
if _, err := assign(ctx, open, "anchor", module); err != nil {
t.Fatal(err)
}
}
if err := open.inventory.SetSettings(ctx, "anchor", "distribution",
map[string]any{catalogue.PortsSetting: map[string]any{"5000": 5101}}); err != nil {
t.Fatal(err)
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
onNetwork, err := whereEveryoneIs(ctx, open.inventory, shelf)
if err != nil || len(onNetwork) != 1 {
t.Fatalf("the lone node is not on the network: %v %v", onNetwork, err)
}
if got, err := artifactStoreAddress(ctx, open.inventory, shelf, "anchor"); err != nil || got != "127.0.0.1:5101" {
t.Fatalf("on the network with no resolver, the mesh of one is handed %q (%v), a name nothing answers", got, err)
}
if got := heldBy(ctx)["mesh-tools/build"]; got != "127.0.0.1:5101/mesh-tools/build@"+aDigest {
t.Fatalf("the builder beside the store is handed the base %q", got)
}
publishesTo := func() string {
t.Helper()
for _, r := range composed(t, open, "anchor").Resources {
if r["path"] == "/etc/mesh-builder.env" {
content, _ := r["content"].(string)
return content
}
}
t.Fatal("the builder's environment was not composed")
return ""
}
if got := publishesTo(); got != "MESH_REGISTRY=127.0.0.1:5101\n" {
t.Fatalf("the builder beside the store publishes to %q, a name nothing answers", got)
}
// A resolver holds the seat: the name is answered, and the store is named by it.
if _, err := open.inventory.SeedSeats(ctx, catalogue.DefaultSeats()); err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "resolver", Version: "1",
Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}},
Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}})
if _, err := assign(ctx, open, "anchor", "resolver"); err != nil {
t.Fatal(err)
}
shelf, err = open.inventory.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if got, err := artifactStoreAddress(ctx, open.inventory, shelf, "anchor"); err != nil || got != "anchor.internal:5101" {
t.Fatalf("with a resolver answering the mesh's names, the store is handed as %q (%v)", got, err)
}
if got := heldBy(ctx)["mesh-tools/build"]; got != "anchor.internal:5101/mesh-tools/build@"+aDigest {
t.Fatalf("the builder is handed the base %q", got)
}
if got := publishesTo(); got != "MESH_REGISTRY=anchor.internal:5101\n" {
t.Fatalf("with a resolver, the builder publishes to %q", got)
}
}
// **A mesh of two in which one machine does not resolve is still a mesh of two** (review 275). The reading of
// who is on the network leaves the unresolvable laptop out, which made it look like a mesh of one; the
// store's own node must keep its internal name, and its builder keep publishing to it.
func TestAMeshOfTwoWithAnUnresolvableMachineKeepsItsNames(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, aStore())
register(t, open, catalogue.Manifest{Module: "builder", Version: "1",
Requires: []string{catalogue.ArtifactStoreProvision},
Resources: []map[string]any{{"id": "env", "type": "file", "path": "/etc/mesh-builder.env",
"content": "MESH_REGISTRY=${bound:" + catalogue.ArtifactStoreProvision + ":at}:${bound:" +
catalogue.ArtifactStoreProvision + ":port}\n"}}})
for _, module := range []string{"distribution", "builder"} {
if _, err := assign(ctx, open, "anchor", module); err != nil {
t.Fatal(err)
}
}
if err := open.inventory.SetSettings(ctx, "anchor", "distribution",
map[string]any{catalogue.PortsSetting: map[string]any{"5000": 5101}}); err != nil {
t.Fatal(err)
}
// The laptop does not resolve: two modules claim one node seat there, straight into the store.
twoRivalsAndANote(t, open)
for _, m := range []string{"rival-one", "rival-two"} {
if _, err := open.inventory.Assign(ctx, "laptop", m); err != nil {
t.Fatal(err)
}
}
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
onNetwork, err := whereEveryoneIs(ctx, open.inventory, shelf)
if err != nil || len(onNetwork) != 1 {
t.Fatalf("the unresolvable laptop is still read as on the network: %v %v", onNetwork, err)
}
if got, err := artifactStoreAddress(ctx, open.inventory, shelf, "anchor"); err != nil || got != "anchor.internal:5101" {
t.Fatalf("a mesh of two read as one: the store's own node is handed %q (%v)", got, err)
}
for _, r := range composed(t, open, "anchor").Resources {
if r["path"] == "/etc/mesh-builder.env" && r["content"] != "MESH_REGISTRY=anchor.internal:5101\n" {
t.Fatalf("a mesh of two read as one: the builder publishes to %v", r["content"])
}
}
}
+60
View File
@@ -721,6 +721,22 @@ func artifactStoreAddress(ctx context.Context, inv *inventory.Inventory,
return "", err
}
if found {
// **The store's own node is not handed a name nothing answers** (novox/hq issue 701). A node's
// internal name is answered by the mesh's resolver (ADR 0194), and genesis places the network
// (its step 21) before anything holds that seat: the filter's build was handed
// anchor.internal:5000 and the machine's runtime could not look it up. Until a resolver holds
// the seat, a mesh of one keeps reaching its store by loopback, as it did before the network.
// A mesh of more than one node is answered as before: loopback is no answer for the others, and
// one name for every node is what a mesh of several without a resolver is still put together to.
if node == forNode {
unanswered, err := aLoneNameNothingAnswers(ctx, inv)
if err != nil {
return "", err
}
if unanswered {
return "127.0.0.1:" + port, nil
}
}
return overlay.InternalName(node) + ":" + port, nil
}
holder, port, found, err := artifactStoreHolder(ctx, inv)
@@ -730,6 +746,50 @@ func artifactStoreAddress(ctx context.Context, inv *inventory.Inventory,
return "127.0.0.1:" + port, nil
}
// aLoneNameNothingAnswers is whether the mesh is one node and nothing will answer that node's internal
// name: the inventory knows exactly one node, and no module holds or claims the mesh's resolver seat
// anywhere (novox/hq ADR 0194, ADR 0223). Genesis is exactly that from its network step until a resolver is
// installed (novox/hq issue 701): the node then reaches what runs on it by loopback, as it did before the
// network, and is not composed a name no lookup can answer.
//
// **Read from the mesh, never from who is on the network** (review 275): that reading leaves out a machine
// whose assignments cannot be read or whose modules do not resolve, and a bad reading must never make a
// mesh of several look like a mesh of one and send its machines to loopback. Every recorded holding and
// every assignment of a claimant counts, wherever it is and whether or not its machine has an address.
func aLoneNameNothingAnswers(ctx context.Context, inv *inventory.Inventory) (bool, error) {
nodes, err := inv.Nodes(ctx)
if err != nil {
return false, err
}
if len(nodes) != 1 {
return false, nil
}
holdings, err := inv.Holdings(ctx)
if err != nil {
return false, err
}
for _, h := range holdings {
if s, ok := catalogue.SeatNamed(h.Claim); ok && s.Name == resolverSeat {
return false, nil
}
}
entries, err := inv.Catalogued(ctx)
if err != nil {
return false, err
}
for _, e := range entries {
if len(e.On) == 0 {
continue
}
for _, c := range e.Manifest.Claims {
if s, ok := catalogue.SeatNamed(c.Name); ok && s.Name == resolverSeat {
return false, nil
}
}
}
return true, nil
}
// artifactStoreHolder is whichever node is assigned a module offering the artifact store, on or
// off the network, and the port that node put it on.
func artifactStoreHolder(ctx context.Context, inv *inventory.Inventory) (node, port string, found bool, err error) {
+14 -1
View File
@@ -103,6 +103,19 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
if err != nil {
return catalogue.Resolution{}, nil, err
}
// **This node's own name, only where something answers it** (novox/hq issue 701). A mesh of one on its
// network with no resolver yet is composed as off it: what it binds to on itself is reached by
// loopback, as before the network, rather than by a name no lookup on the machine can answer.
ownName := onNetwork[nodeName]
if ownName != "" {
unanswered, err := aLoneNameNothingAnswers(ctx, inv)
if err != nil {
return catalogue.Resolution{}, nil, err
}
if unanswered {
ownName = ""
}
}
// What this mesh can answer with a record rather than a machine, and which record each of
// this node's modules was put on. Read across a context boundary by name, which is what
@@ -136,7 +149,7 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
resolved, err := catalogue.Resolve(shelf, assigned,
catalogue.Node{Name: nodeName, Site: site, Capabilities: capabilities,
At: onNetwork[nodeName], PublicDomain: publicDomain,
At: ownName, PublicDomain: publicDomain,
Account: who.Account, AccountHome: who.AccountHome,
AgentAccount: who.AgentAccount, AgentAccountHome: who.AgentAccountHome}, world)
if err != nil {
+2
View File
@@ -18,6 +18,8 @@
./cmd/mesh-controller TestTheConditionsVerbReadsTheLooksTheBucketNoLongerHolds
./internal/inventory TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid
./cmd/mesh-controller TestAWalkLetGoAndStoppedByVerbsIsSaidStartedAndEnded
./cmd/mesh-controller TestAMeshOfOneIsHandedNoNameUntilAResolverAnswersIt
./cmd/mesh-controller TestAMeshOfTwoWithAnUnresolvableMachineKeepsItsNames
./cmd/mesh-controller TestAWalkStoppedOrClosedByAPersonIsSaidEndedStopped
./cmd/mesh-controller TestAWalkRetriedByAPersonIsSaidStartedAgain
./cmd/mesh-controller TestABacklogReleasedByAPersonIsSaidStarted