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.
This commit is contained in:
jochen
2026-10-14 08:32:15 +02:00
parent 3479a35462
commit eb99284890
3 changed files with 117 additions and 0 deletions
+89
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,91 @@ 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}}})
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)
}
// 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)
}
}
+27
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 && len(on) == 1 {
answered, err := meshNamesAnswered(ctx, inv, shelf)
if err != nil {
return "", err
}
if !answered {
return "127.0.0.1:" + port, nil
}
}
return overlay.InternalName(node) + ":" + port, nil
}
holder, port, found, err := artifactStoreHolder(ctx, inv)
@@ -730,6 +746,17 @@ func artifactStoreAddress(ctx context.Context, inv *inventory.Inventory,
return "127.0.0.1:" + port, nil
}
// meshNamesAnswered is whether something answers the mesh's internal names: a holder of the mesh's
// resolver seat on the private network (novox/hq ADR 0194, ADR 0223), as a machine's resolver file
// lists them (replicatedHolders).
func meshNamesAnswered(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest) (bool, error) {
holders, err := replicatedHolders(ctx, inv, shelf)
if err != nil {
return false, err
}
return len(holders[resolverSeat]) > 0, 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) {
+1
View File
@@ -18,6 +18,7 @@
./cmd/mesh-controller TestTheConditionsVerbReadsTheLooksTheBucketNoLongerHolds
./internal/inventory TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid
./cmd/mesh-controller TestAWalkLetGoAndStoppedByVerbsIsSaidStartedAndEnded
./cmd/mesh-controller TestAMeshOfOneIsHandedNoNameUntilAResolverAnswersIt
./cmd/mesh-controller TestAWalkStoppedOrClosedByAPersonIsSaidEndedStopped
./cmd/mesh-controller TestAWalkRetriedByAPersonIsSaidStartedAgain
./cmd/mesh-controller TestABacklogReleasedByAPersonIsSaidStarted