From 0e2b288bb6f5d5ebba487850c2256c0e8b48f62e Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 31 Aug 2026 11:09:49 +0200 Subject: [PATCH] A container can be told where to resolve names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A container does not inherit the machine's names. It gets its own /etc/hosts holding its own hostname, and a runtime rewrites resolv.conf — so every internal name the mesh wrote for that machine is invisible to what the machine is running. That was hit for real, in the lab: a database client on one node could not resolve another node, on a mesh where both names were correct and present on both machines. It was worked around by resolving on the host and passing an address, which is the kind of workaround that should not be needed twice. A field on an existing shape, not a ninth shape — the vocabulary is still the eight the count asserts. Per container rather than by editing the machine's resolver configuration: that file belongs to something else on most machines, and a host that edited it would be fighting whatever owns it on every boot — the fault this host exists to avoid, in the place it would be hardest to see. A container told nothing is run exactly as before. Most containers should resolve whatever the machine resolves, and passing an empty flag would be a change of behaviour dressed up as a default. --- internal/apply/apply.go | 7 +++ internal/apply/apply_test.go | 72 +++++++++++++++++++++++++++++ internal/declaration/declaration.go | 13 ++++++ 3 files changed, 92 insertions(+) diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 16ff97f..f50c470 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -812,6 +812,13 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner) ( for _, v := range r.Volumes { args = append(args, "--volume", v) } + for _, n := range r.Nameservers { + // Per container rather than by changing the machine's resolver configuration. That file + // belongs to something else on most machines, and a host that edited it would be fighting + // whatever owns it on every boot — the fault this host exists to avoid, in the one place + // it would be hardest to see. + args = append(args, "--dns", n) + } args = append(args, r.Image) args = append(args, r.Args...) diff --git a/internal/apply/apply_test.go b/internal/apply/apply_test.go index 086ab4d..88d1bf1 100644 --- a/internal/apply/apply_test.go +++ b/internal/apply/apply_test.go @@ -1224,3 +1224,75 @@ func TestAFailedActionStopsWhatFollows(t *testing.T) { t.Errorf("the message does not say the rest was not tried: %v", err) } } + +// A container is told which resolver to use, because it does not inherit the machine's names. +// +// A container gets its own `/etc/hosts` holding its own hostname, and a runtime rewrites +// `resolv.conf` — so every internal name the mesh wrote for the machine is invisible to what the +// machine is running. That was hit for real: 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 TestAContainerIsToldWhichResolverToUse(t *testing.T) { + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + if name != "docker" { + return "", errors.New("not installed") + } + switch args[0] { + case "info": + return "29.0.0\n", nil + case "inspect": + return "false\t\n", errors.New("no such container") + case "run": + ran = args + return "deadbeef\n", nil + } + return "", nil + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"app","type":"container","name":"app","image":"`+pinned+`", + "nameservers":["10.42.0.1"]} + ]}`) + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) + + var told bool + for i, a := range ran { + if a == "--dns" && i+1 < len(ran) && ran[i+1] == "10.42.0.1" { + told = true + } + } + if !told { + t.Fatalf("the container was not told where to resolve names: %v", ran) + } +} + +// And a container that was told nothing is run exactly as before: most containers resolve +// whatever the machine resolves, and passing an empty flag would be a change of behaviour +// dressed as a default. +func TestAContainerToldNothingIsRunAsBefore(t *testing.T) { + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + if name != "docker" { + return "", errors.New("not installed") + } + switch args[0] { + case "info": + return "29.0.0\n", nil + case "inspect": + return "false\t\n", errors.New("no such container") + case "run": + ran = args + return "deadbeef\n", nil + } + return "", nil + } + d := parseTrusted(t, `{"declaration":1,"resources":[ + {"id":"app","type":"container","name":"app","image":"`+pinned+`"} + ]}`) + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil) + + for _, a := range ran { + if a == "--dns" { + t.Fatalf("a container that was told nothing was given a resolver anyway: %v", ran) + } + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 1b995d3..3092d17 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -328,6 +328,19 @@ type Container struct { Ports []string `json:"ports,omitempty"` Volumes []string `json:"volumes,omitempty"` Args []string `json:"args,omitempty"` + // Nameservers this container resolves through. + // + // **Because a container does not inherit the machine's names.** It gets its own `/etc/hosts` + // holding its own hostname, and a runtime rewrites `resolv.conf` — so every internal name the + // mesh wrote for this machine is invisible to the thing the machine is running. That was hit + // for real: a database client on one node could not resolve another node, on a mesh where + // both names were correct and present. + // + // Set by the mesh rather than by a module: which resolver a machine has is a fact about the + // machine, and a module that named one would be a module that only runs where somebody put + // that resolver. + Nameservers []string `json:"nameservers,omitempty"` + // Network is the container's network, passed to the runtime unchanged. // // Needed because the control plane must reach the store and the broker on the machine it was