From 4d67c48342e190b593d340e92ef5154f66685af1 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 11:13:34 +0200 Subject: [PATCH] Every container is given the mesh's names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal names are written to the machine's hosts file, which serves the machine and not what the machine runs: a container gets its own hosts file holding only its own hostname. So every name the mesh wrote was invisible to the majority of things that need one — and on the machine it always worked, which is exactly what made it easy to miss. It was hit for real in the lab, and worked around by resolving the address on the machine and passing it in. That workaround is now removed, and its absence is the assertion. A file rather than a resolver, which is the decision the mesh already made about names and this extends rather than overturns: it works on every runtime, needs no package and has no failure mode of its own. The stated trigger for a resolver — names that are not one-per-node, service names, wildcards — is still not met. Given by the mesh, not chosen by a module: a module that listed the machines would go stale the day one joins, and one that did not would be a module whose containers cannot reach anything by name. A container that named its own keeps them and gets the mesh's beside them. Only containers, and not the ones on the machine's own network: a runtime refuses to write a hosts file for those, and a file or a service given the field is a declaration the host refuses outright — so getting it wrong breaks the whole machine for something that was never about names. --- cmd/mesh-control/main.go | 31 ++++++- internal/catalogue/declaration.go | 54 +++++++++++ internal/catalogue/names_test.go | 143 ++++++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/catalogue/names_test.go diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index 036d557..d09ee7d 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -1353,9 +1353,17 @@ func declarationWith(ctx context.Context, inv *inventory.Inventory, node string, return nil, err } + // And every machine's name, so a container can reach one. The same set that writes the + // machine's own hosts file — one reading, so a container and its machine cannot disagree + // about where another machine is. + names, err := namesInTheMesh(ctx, inv) + if err != nil { + return nil, err + } + return plan.Declaration(catalogue.Rendering{ Settings: settings, Generators: gens, Grants: grants, Needed: needed, - Certificate: certificate, Authority: authority, Mesh: private}) + Certificate: certificate, Authority: authority, Mesh: private, Names: names}) } // onThePrivateNetwork is every node's address on the overlay, sorted. @@ -2736,3 +2744,24 @@ func wouldSend(ctx context.Context, inv *inventory.Inventory, } return out, nil } + +// namesInTheMesh is every machine's internal name and the address behind it. +// +// A machine with no address has no name: writing one that resolves to nothing is worse than not +// writing it, because a connection to an address that does not answer hangs where a name that +// does not resolve fails at once and says so. That is the rule the hosts file already follows, +// and this is the same set read the same way. +func namesInTheMesh(ctx context.Context, inv *inventory.Inventory) (map[string]string, error) { + places, err := inv.Overlays(ctx) + if err != nil { + return nil, err + } + out := map[string]string{} + for _, p := range places { + if strings.TrimSpace(p.Address) == "" { + continue + } + out[overlay.InternalName(p.Name)] = p.Address + } + return out, nil +} diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index b3f189b..6cefc81 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -80,6 +80,13 @@ type Rendering struct { // a fact about the mesh, and resolution answers questions about one machine. Mesh []string + // Names is every machine's internal name and its address, for containers to be given. + // + // **A container does not inherit the machine's names**, so every internal name the mesh wrote + // is invisible to what the machine runs. Given here rather than looked up on the machine, + // because which machines exist is a fact about the mesh. + Names map[string]string + Settings SettingsBy Generators map[string]Generator // Grants are the credentials this node must create, for the provisions it offers. Passed in @@ -284,6 +291,17 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { // merging earlier would throw away the files it still needs. resources = append(append([]map[string]any{}, first...), resources...) + // Every container is given the mesh's names. Not a choice a module makes: a module that + // listed them would go stale the day a machine joins, and one that did not would be a + // module whose containers cannot reach anything by name. + // + // A container that was given names of its own keeps them and gets the mesh's beside them: + // the mesh does not know what else a workload needs to reach, and taking something away + // to add something is not what "also" means. + if len(with.Names) > 0 { + resources = withMeshNames(resources, with.Names) + } + for _, unsettled := range resources { resource, err := ApplySettings(unsettled, with.Settings[m.Module]) if err != nil { @@ -517,3 +535,39 @@ func here(r Resolution, requirement string) *Needed { } return nil } + +// withMeshNames gives every container in a set the mesh's names. +// +// Copied rather than edited in place: these maps come from a module's manifest, and mutating one +// would change what the catalogue holds for every other machine running that module. +func withMeshNames(resources []map[string]any, names map[string]string) []map[string]any { + out := make([]map[string]any, 0, len(resources)) + for _, r := range resources { + if r["type"] != "container" { + out = append(out, r) + continue + } + // A container on the machine's own network shares its hosts file already, and a runtime + // refuses to write one for it. Adding names there would be an argument the runtime + // rejects, which fails the whole container for something it did not need. + if network, on := r["network"].(string); on && network == "host" { + out = append(out, r) + continue + } + + copied := map[string]any{} + for k, v := range r { + copied[k] = v + } + var given []any + if already, ok := copied["hosts"].([]any); ok { + given = append(given, already...) + } + for _, name := range sortedKeys(names) { + given = append(given, name+":"+names[name]) + } + copied["hosts"] = given + out = append(out, copied) + } + return out +} diff --git a/internal/catalogue/names_test.go b/internal/catalogue/names_test.go new file mode 100644 index 0000000..5e93140 --- /dev/null +++ b/internal/catalogue/names_test.go @@ -0,0 +1,143 @@ +package catalogue + +import ( + "strings" + "testing" +) + +func containersOf(t *testing.T, r Resolution, with Rendering) []map[string]any { + t.Helper() + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + var found []map[string]any + for _, res := range out { + if res["type"] == "container" { + found = append(found, res) + } + } + return found +} + +func namesOf(r map[string]any) []string { + var out []string + if given, ok := r["hosts"].([]any); ok { + for _, h := range given { + out = append(out, h.(string)) + } + } + return out +} + +// A container does not inherit the machine's names, so the mesh gives them to it. +// +// It gets its own hosts file holding only its own hostname — every internal name the mesh wrote +// for the machine is invisible to what the machine runs. A database client on one node could not +// resolve another node, on a mesh where both names were correct and present on both machines. +func TestEveryContainerIsGivenTheMeshsNames(t *testing.T) { + got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{ + Module: "app", + Resources: []map[string]any{{"id": "web", "type": "container", "name": "web", + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}, + }}}, Rendering{Names: map[string]string{ + "anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", + }}) + if len(got) != 1 { + t.Fatalf("expected one container, got %d", len(got)) + } + given := namesOf(got[0]) + if len(given) != 2 { + t.Fatalf("the container was given %d name(s): %v", len(given), given) + } + if given[0] != "anchor.internal:10.42.0.1" { + t.Fatalf("the name is not in the form a runtime writes: %v", given) + } +} + +// A container that named its own keeps them and gets the mesh's beside them. +// +// The mesh does not know what else a workload needs to reach, and taking something away in order +// to add something is not what "also" means. +func TestAContainersOwnNamesAreKept(t *testing.T) { + got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{ + Module: "app", + Resources: []map[string]any{{"id": "web", "type": "container", "name": "web", + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64), + "hosts": []any{"something.else:203.0.113.9"}}}, + }}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}}) + + given := namesOf(got[0]) + if len(given) != 2 || given[0] != "something.else:203.0.113.9" { + t.Fatalf("the container's own names were lost: %v", given) + } +} + +// A container on the machine's own network already shares its hosts file, and a runtime refuses +// to write one for it — so adding names there fails the whole container for something it did not +// need. +func TestAContainerOnTheMachinesNetworkIsLeftAlone(t *testing.T) { + got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{ + Module: "control", + Resources: []map[string]any{{"id": "c", "type": "container", "name": "c", + "image": "registry.example/c@sha256:" + strings.Repeat("a", 64), "network": "host"}}, + }}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}}) + + if len(namesOf(got[0])) != 0 { + t.Fatalf("a host-networked container was given names a runtime will refuse: %v", got[0]) + } +} + +// A mesh with no private network gives nothing, rather than a name with no address behind it. +func TestAMeshWithNoNamesGivesNone(t *testing.T) { + got := containersOf(t, Resolution{Node: "alone", Modules: []Manifest{{ + Module: "app", + Resources: []map[string]any{{"id": "web", "type": "container", "name": "web", + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}, + }}}, Rendering{}) + if len(namesOf(got[0])) != 0 { + t.Fatalf("names were invented for a mesh that has none: %v", got[0]) + } +} + +// The catalogue's copy is not edited: these maps come from a manifest, and mutating one would +// change what every other machine running that module is given. +func TestGivingNamesDoesNotChangeTheCatalogue(t *testing.T) { + held := map[string]any{"id": "web", "type": "container", "name": "web", + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)} + module := Manifest{Module: "app", Resources: []map[string]any{held}} + + for _, node := range []string{"one", "two"} { + containersOf(t, Resolution{Node: node, Modules: []Manifest{module}}, + Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}}) + } + if _, changed := held["hosts"]; changed { + t.Fatal("the manifest the catalogue holds was edited, so every machine now carries this") + } +} + +// Only containers. A file or a service given a `hosts` key is a declaration the host refuses +// outright — it takes no unknown field — so getting this wrong breaks the whole machine rather +// than one resource, and breaks it for something that was never about names. +func TestNothingButAContainerIsGivenNames(t *testing.T) { + out, err := Resolution{Node: "laptop", Modules: []Manifest{{ + Module: "app", + Resources: []map[string]any{ + {"id": "conf", "type": "file", "path": "/etc/app.conf", "content": "x", "mode": "0644"}, + {"id": "run", "type": "service", "unit": "app.service", "state": "running"}, + {"id": "web", "type": "container", "name": "web", + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}, + }, + }}}.Declaration(Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}}) + if err != nil { + t.Fatal(err) + } + for _, r := range out { + if r["type"] == "container" { + continue + } + if _, given := r["hosts"]; given { + t.Fatalf("a %v was given names, which the host will refuse: %v", r["type"], r) + } + } +}