Files
mesh-controller/internal/catalogue/facts.go
T
jochen 51163b9f14 The mesh's names are written into the hosts file, not over it (hq 128)
/etc/hosts is the machine's: the distribution's localhost lines, the
operator's own entries, and marked blocks other tools maintain there.
Writing node-names whole replaced all of it the moment the private
network was taken, and every later write by those tools was lost at
the next machine joining. The node-names fact is now emitted with
into: "block", so the host owns only its marked region and keeps the
rest byte for byte. The region holds only the mesh's names: no header
claiming the file, no localhost, no 127.0.1.1 line — the floor was
never the mesh's to write. How a fact is written is a property of the
fact in the closed table; node-zones stays a whole file the mesh owns.

Sequencing: a host older than the block mode refuses the whole
declaration on an unknown into, so every host must be upgraded before
this controller is rolled out.
2026-09-26 23:46:51 +02:00

229 lines
10 KiB
Go

package catalogue
import (
"fmt"
"sort"
"strings"
)
// What only the mesh knows, written where a module asks for it.
//
// **The graph is the control plane's; using it is the module's.** The mesh knows which machines
// exist, what they are called, and where they are. Turning that into a name that resolves is
// somebody's software, and which software is a choice the mesh should not be making.
//
// This replaced three modules — names, a resolver's data, and the private network's own
// configuration — that existed only because computed output needed somewhere to live. They ran no
// software and could not be swapped for anything, which is the test of whether something is a
// module at all (novox/hq ADR 0040).
const (
// FactNodeNames is every machine's name and address, as lines of a hosts file.
//
// Written *into* the machine's hosts file as a region of its own, never as the file: the rest
// of that file is the distribution's, the operator's and other tools' (novox/hq issue 128).
// Exact names only: `homer` and `homer.internal` resolve to homer. Anything *under* a machine
// is a wildcard, which a hosts file cannot express — that is FactNodeZones.
FactNodeNames = "node-names"
// FactNodeZones is every machine as a wildcard: `*.homer.internal` is homer.
//
// Written in the form a resolver reads. A machine's own name and everything under it are one
// fact — if homer is at an address, so is anything homer serves.
FactNodeZones = "node-zones"
)
// fact is one thing the mesh computes, and how it is written.
type fact struct {
// render is the fact's content. A fact is written from the names it is about. `every` is every
// name the mesh serves — machines and the names it was told to route; `machines` is only the
// machines. A fact takes the set it is true of, and the two must not be confused
// (novox/hq 04-ISSUES/111).
render func(r Resolution, every, machines map[string]string, suffix string) string
// shared is whether the file the fact goes to belongs to the machine rather than to the mesh.
//
// **A property of the fact, not of the path a module asked for.** A hosts file is the
// machine's wherever it lives: the distribution put `localhost` in it, the operator added
// their own lines, and a local development tool keeps marked blocks of its own there. The
// mesh writing it whole replaced all of that the moment the private network was taken, and
// every later write by the other tool was lost at the next machine joining — silently, with
// both sides believing they owned the file (novox/hq issue 128). That is ADR 0102's failure in
// a file 0102 did not name, because its merge is structured and a hosts file is not; the
// answer is the same idea for text — a marked region the host owns, everything outside it
// kept byte for byte. A resolver's zones file is the other way about: the mesh owns it, and
// nothing else writes there.
shared bool
}
// facts is every fact the mesh computes, and how each is written.
//
// **A closed list.** A module asking for a fact the mesh does not have is asking for a file nobody
// will write, and finding that out on a machine — as a daemon that starts, reads nothing, and
// answers no queries — is worse than being told where the manifest is.
var facts = map[string]fact{
FactNodeNames: {
render: func(r Resolution, every, _ map[string]string, suffix string) string {
return nodeNames(r, every, suffix)
},
shared: true,
},
FactNodeZones: {
render: func(r Resolution, _, machines map[string]string, suffix string) string {
return nodeZones(r, machines, suffix)
},
},
}
// FactsInto renders the facts a module asked for, as files it will be given.
//
// The module owns everything after the file exists: loading it, restarting on it, what a resolver
// does with it. This only puts it there.
func FactsInto(m Manifest, r Resolution, addresses, machines map[string]string, suffix string) ([]map[string]any, error) {
if len(m.Facts) == 0 {
return nil, nil
}
names := make([]string, 0, len(m.Facts))
for name := range m.Facts {
names = append(names, name)
}
sort.Strings(names)
out := make([]map[string]any, 0, len(names))
for _, name := range names {
f, known := facts[name]
if !known {
return nil, fmt.Errorf(
"%s asks the mesh for %q, which it does not compute. It has %s",
m.Module, name, spokenFacts())
}
path := m.Facts[name]
if !strings.HasPrefix(path, "/") {
return nil, fmt.Errorf(
"%s asks for %q at %q, which is not an absolute path", m.Module, name, path)
}
file := map[string]any{
"id": "fact-" + name, "type": "file", "path": path, "mode": "0644",
"content": f.render(r, addresses, machines, suffix),
}
if f.shared {
// The host owns only the lines between `# BEGIN mesh <id>` and `# END mesh <id>` and
// keeps the rest of the file byte for byte; undeclared, the region goes and nothing
// else does. Replacing nothing, it is written on an adopted node without being held,
// so a machine is named on the private network before its module is taken.
//
// **Hosts first, then this.** A host older than the block mode refuses the whole
// declaration on an `into` it does not know — not just this file, everything — so
// every host is upgraded before a controller emitting it is rolled out, the same
// order ADR 0102 set for `into: json` (novox/hq issue 128).
file["into"] = "block"
}
out = append(out, file)
}
return out, nil
}
// spokenFacts lists them, so a refusal says what would have worked.
func spokenFacts() string {
names := make([]string, 0, len(facts))
for name := range facts {
names = append(names, name)
}
sort.Strings(names)
return strings.Join(names, ", ")
}
// nodeNames is every machine's name and address, as the mesh's region of a hosts file.
//
// **Only the mesh's names.** No header claiming the file, no `localhost`, no `127.0.1.1` line for
// the machine itself. Those used to be written here as the floor every Linux expects, because the
// mesh wrote the whole file and removing them would have broken things with nothing to do with
// the mesh. They were never the mesh's: the distribution wrote them before the mesh arrived and
// will want them after it leaves, and a machine's own name belongs to whoever named the machine.
// Now the host writes this into a marked region (novox/hq issue 128) and leaves the rest of the
// file as it found it, so the floor stays where it always was — the machine's — and the mesh
// writing a second `localhost` beside it would be one more line nobody could say the owner of.
// The one comment line is for a person reading the file: which lines are the mesh's, and that
// editing them is pointless.
//
// **A machine with no address is left out.** The mesh has a record for it — somebody added it —
// and does not yet know where it is, which is the ordinary state between adding a machine and it
// joining. Writing the name anyway would give a name that resolves to nothing, and a connection to
// that hangs; leaving it out fails at once and says the name is unknown.
func nodeNames(r Resolution, addresses map[string]string, suffix string) string {
var b strings.Builder
b.WriteString("# The mesh's names. This region is replaced whenever a machine joins or leaves.\n")
for _, name := range sortedNames(addresses) {
at := addresses[name]
internal, bare := meshName(name, suffix)
// Its mesh name resolves to its address on the private network rather than to loopback,
// so a service binding the name it was given stays reachable from everywhere else. The
// bare name may be answered first by a line of the machine's own — `127.0.1.1 homer`,
// above the region — and that is the machine's choice to have made; the mesh name is the
// one nothing else in the file writes.
fmt.Fprintf(&b, "%s\t%s\t%s", at, internal, bare)
if bare == r.Node {
b.WriteString("\t# this machine")
}
b.WriteString("\n")
}
return b.String()
}
// nodeZones is every machine as a wildcard, in the form a resolver reads.
//
// `*.homer.internal` is homer, which is the whole rule: if homer is at an address, so is anything
// homer serves. A module wanting this runs the resolver; the mesh only says what is true.
//
// **And the suffix itself, as a local domain.** A resolver that forwards what it cannot answer
// would otherwise send a mesh name it does not know — a machine that left, a typo — to a public
// resolver, which is a leak of the mesh's names for no answer. `local=` keeps everything under the
// suffix here: answered from the lines below or refused. Written in this file rather than in the
// resolver's own configuration because the suffix is the mesh's choice (the operator may have
// picked another) and this file is the one place the mesh writes what it chose.
func nodeZones(_ Resolution, addresses map[string]string, suffix string) string {
var b strings.Builder
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever a machine\n")
b.WriteString("# joins or leaves, and an edit would survive until then and vanish.\n\n")
fmt.Fprintf(&b, "local=/%s/\n", strings.TrimPrefix(suffixOr(suffix), "."))
for _, name := range sortedNames(addresses) {
internal, _ := meshName(name, suffix)
fmt.Fprintf(&b, "address=/%s/%s\n", internal, addresses[name])
}
return b.String()
}
// meshName is a machine's internal name and its bare one, from either. The control plane keys
// the names it hands a resolution by the internal name (`homer.internal`), the same map a
// container gets as its hosts; a caller that keys by the bare name gets the same answer. The
// suffix is the one the control plane composed those names with, handed down rather than written
// here a second time — the alternative was `homer.internal.internal` on every machine.
func meshName(name, suffix string) (internal, bare string) {
dotted := "." + strings.TrimPrefix(suffixOr(suffix), ".")
if strings.HasSuffix(name, dotted) {
return name, strings.TrimSuffix(name, dotted)
}
return name + dotted, name
}
// suffixOr is the suffix given, or the one the mesh composes names with when none was handed down.
// The one place the default is written in this file, so a fact and a name cannot disagree about it.
func suffixOr(suffix string) string {
if suffix == "" {
return "internal"
}
return suffix
}
func sortedNames(addresses map[string]string) []string {
out := make([]string, 0, len(addresses))
for name, at := range addresses {
// See nodeNames: a machine the mesh cannot place is left out rather than named at nothing.
if at == "" {
continue
}
out = append(out, name)
}
sort.Strings(out)
return out
}