Merge pull request 'Rename hosts to hostname, which also writes /etc/hostname (hq ADR 0223 part 3)' (#78) from hostname-module into main
mesh/delivery held for a person: merged without a passing check: only a person decides that it goes on

This commit was merged in pull request #78.
This commit is contained in:
2026-10-05 22:15:29 +00:00
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"
]
}
]
}
}