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) + } + } +}