/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.
229 lines
10 KiB
Go
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
|
|
}
|