Rename hosts to hostname, which also writes /etc/hostname (hq ADR 0223)

Two files say one fact, the machine's name, and nothing owned /etc/hostname.
The name written is the operator's hostname setting, with no default: three
of four machines call themselves something other than their mesh name, and
renaming one is the operator's call. It takes effect at the next boot.
This commit is contained in:
jochen
2026-10-05 23:43:14 +02:00
parent 0515db043a
commit 138d9afd7b
8 changed files with 127 additions and 57 deletions
+34
View File
@@ -0,0 +1,34 @@
# hostname
The machine's names (novox/hq ADR 0199, ADR 0223): it holds the node seat `node-hostname` and owns
the two files that say what a machine is called — `/etc/hostname` and the machine's own lines in
`/etc/hosts`. It was `hosts`, holding `node-hosts-file`; the seat was renamed, and the old name
resolves to it as an alias.
## What it writes
- **`/etc/hostname`, whole**: the module's `hostname` setting, and nothing else. There is no
default. A machine's name is the operator's: the mesh's name for a machine and the name it calls
itself need not be the same, and writing the mesh's name silently would rename a machine. Set it
per machine — `settings set hostname '{"hostname": "<name>"}' --node <node>` — before the module
is assigned there; without it the module is left out of that machine's declaration, naming the
key. A mesh-wide `{"hostname": "${machine:name}"}` makes every machine call itself by its mesh
name, and a machine's own setting still overrides it.
- **The machine's own lines in `/etc/hosts`**, as the mesh's marked region at the start of the file:
`localhost` and `127.0.1.1` with the machine's mesh name. Every other line is the operator's, kept
byte for byte and given back when the module goes.
## When a new name takes effect
At the machine's **next boot**. The kernel's name is set from `/etc/hostname` when the machine
starts; writing the file changes what `hostnamectl` reports as the static name and nothing that is
running. The module declares nothing that would set it live: a graphical session's X authority is
keyed by the name the session started under, so changing it underneath a running session refuses
every new window until the person logs in again. Reboot when that is acceptable, or run
`hostnamectl hostname <name>` by hand.
## Its verbs
`<node>/node-hostname.entries`, `.add` and `.remove`: the hosts file's lines, each marked whose it
is; add one address and its names to the operator's lines; remove a name or an address from them.
They change the machine's file and nothing else. `/etc/hostname` has no verb — it is the setting.
@@ -72,7 +72,7 @@ func execRunner(ctx context.Context, name string, args ...string) (string, error
type Line struct {
// Text is the line exactly as it is in the file.
Text string `json:"text"`
// Owner is whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator".
// Owner is whose it is: the block's id (`mesh hostname.own`, or another tool's) or "operator".
Owner string `json:"owner"`
// Address and Names are an entry's; absent for a comment or a blank line.
Address string `json:"address,omitempty"`
@@ -319,7 +319,7 @@ func (h HostsFile) write(ctx context.Context, content string) error {
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
return err
}
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hosts-tools")
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hostname-tools")
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
return err
}
@@ -17,10 +17,10 @@ import (
const file = "# Static table lookup for hostnames.\n" +
"127.0.0.1\tlocaldev.example.com\n" +
"127.0.0.1 a.example.com b.example.com\n" +
"# BEGIN mesh hosts.own\n" +
"# BEGIN mesh hostname.own\n" +
"127.0.0.1\tlocalhost\n" +
"::1\tlocalhost\n" +
"# END mesh hosts.own\n" +
"# END mesh hostname.own\n" +
"# BEGIN other-tool\n" +
"192.0.2.7\tproject.test\n" +
"# END other-tool\n"
@@ -47,7 +47,7 @@ func TestEveryLineSaysWhoseItIs(t *testing.T) {
if lines[0].Address != "" || lines[0].Owner != Operator {
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
}
if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" {
if lines[3].Owner != "mesh hostname.own" || lines[4].Owner != "mesh hostname.own" || lines[6].Owner != "mesh hostname.own" {
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
}
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
@@ -146,7 +146,7 @@ func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
if err != nil || n != 2 {
t.Fatalf("%d %v", n, err)
}
if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") {
if !strings.Contains(all, "# BEGIN mesh hostname.own\n127.0.0.1\tlocalhost\n") {
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
}
if !reflect.DeepEqual(blocks(all), blocks(file)) {
@@ -158,7 +158,7 @@ func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
t.Errorf("%v", err)
}
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") {
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hostname.own") {
t.Errorf("%v", err)
}
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
@@ -200,7 +200,7 @@ func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
t.Fatalf("%+v %v", added, err)
}
beside := filepath.Join(dir, ".hosts.hosts-tools")
beside := filepath.Join(dir, ".hosts.hostname-tools")
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
t.Errorf("%v", calls)
@@ -284,3 +284,30 @@ func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
t.Errorf("%v", got)
}
}
// /etc/hostname is the module's whole file (novox/hq ADR 0223), and what it says is the operator's
// `hostname` setting — never the mesh's name for the machine written silently: on the mesh this was
// built for, three of four machines call themselves something else, and renaming a machine is the
// operator's to decide.
func TestTheMachinesNameIsTheOperatorsSetting(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if r["path"] != "/etc/hostname" {
continue
}
if r["id"] != "name" || r["into"] != nil || r["content"] != "${setting:hostname}\n" {
t.Errorf("/etc/hostname is not written whole from the hostname setting: %v", r)
}
return
}
t.Error("the module does not write /etc/hostname")
}
@@ -1,7 +1,9 @@
// hosts-tools (novox/hq ADR 0199): the hosts file's tools. One binary, launched by the machine's tool
// runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198): the
// node-hosts-file seat's three verbs — the file's lines with whose each is, add an operator's line,
// remove one. They change the machine's file and nothing else; the controller holds none of it.
// hostname-tools (novox/hq ADR 0199, ADR 0223): the tools of the machine's names. One binary,
// launched by the machine's tool runtime and speaking MCP to it over stdio through the Go SDK (ADR
// 0193, ADR 0198): the node-hostname seat's three verbs — the hosts file's lines with whose each
// is, add an operator's line, remove one. They change the machine's file and nothing else; the
// controller holds none of it. /etc/hostname has no verb: it is the module's resource, set by the
// module's `hostname` setting.
//
// stdout is the MCP channel; everything this module says, it says on stderr.
package main
@@ -17,11 +19,11 @@ import (
)
// Seat is the role this module holds.
const Seat = "node-hosts-file"
const Seat = "node-hostname"
func main() {
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
fmt.Fprintf(os.Stderr, "[hosts] %v\n", err)
fmt.Fprintf(os.Stderr, "[hostname] %v\n", err)
os.Exit(1)
}
}
@@ -61,7 +63,7 @@ func namesArg(a map[string]any) []string {
}
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
// subject, as <node>/node-hosts-file.<verb>.
// subject, as <node>/node-hostname.<verb>.
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
}
@@ -1,4 +1,4 @@
module hosts
module hostname
go 1.25.0
+48
View File
@@ -0,0 +1,48 @@
{
"module": "hostname",
"version": "1",
"claims": [
{
"name": "node-hostname",
"scope": "node",
"serves": [
"entries",
"add",
"remove"
]
}
],
"resources": [
{
"id": "own",
"type": "file",
"path": "/etc/hosts",
"mode": "0644",
"into": "block",
"at": "start",
"content": "# The machine's own names (module hostname, novox/hq ADR 0199, ADR 0223). Every line outside this\n# block is the operator's: kept across every push, changed through the node-hostname verbs add and\n# remove, and given back when this module goes. The mesh's names are not here: the mesh's resolver\n# answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
},
{
"id": "name",
"type": "file",
"path": "/etc/hostname",
"mode": "0644",
"content": "${setting:hostname}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/hostname-tools",
"binary": "hostname-tools",
"loads": [
"hostname-tools"
]
}
]
}
}
-41
View File
@@ -1,41 +0,0 @@
{
"module": "hosts",
"version": "1",
"claims": [
{
"name": "node-hosts-file",
"scope": "node",
"serves": [
"entries",
"add",
"remove"
]
}
],
"resources": [
{
"id": "own",
"type": "file",
"path": "/etc/hosts",
"mode": "0644",
"into": "block",
"at": "start",
"content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/hosts-tools",
"binary": "hosts-tools",
"loads": [
"hosts-tools"
]
}
]
}
}