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.
314 lines
10 KiB
Go
314 lines
10 KiB
Go
package main
|
|
|
|
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
|
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
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 hostname.own\n" +
|
|
"127.0.0.1\tlocalhost\n" +
|
|
"::1\tlocalhost\n" +
|
|
"# END mesh hostname.own\n" +
|
|
"# BEGIN other-tool\n" +
|
|
"192.0.2.7\tproject.test\n" +
|
|
"# END other-tool\n"
|
|
|
|
func blocks(text string) []string {
|
|
var out []string
|
|
for _, l := range Parse(text) {
|
|
if l.Owner != Operator {
|
|
out = append(out, l.Text)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestEveryLineSaysWhoseItIs(t *testing.T) {
|
|
lines := Parse(file)
|
|
if len(lines) != 10 {
|
|
t.Fatalf("%d lines: %+v", len(lines), lines)
|
|
}
|
|
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
|
|
if !reflect.DeepEqual(lines[1], want) {
|
|
t.Errorf("%+v", lines[1])
|
|
}
|
|
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 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"}) {
|
|
t.Errorf("%+v", lines[8])
|
|
}
|
|
if lines[5].Address != "::1" {
|
|
t.Errorf("an IPv6 entry: %+v", lines[5])
|
|
}
|
|
}
|
|
|
|
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
|
|
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
|
|
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
|
|
t.Errorf("%+v", l)
|
|
}
|
|
}
|
|
|
|
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
|
|
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
|
|
for _, l := range lines {
|
|
if l.Owner != "x" {
|
|
t.Errorf("%+v", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
|
|
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
|
|
t.Errorf("%q", after)
|
|
}
|
|
if !reflect.DeepEqual(blocks(after), blocks(file)) {
|
|
t.Errorf("blocks changed")
|
|
}
|
|
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
|
|
t.Errorf("a name already there changed the file")
|
|
}
|
|
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
|
|
t.Errorf("%q", some)
|
|
}
|
|
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
|
|
t.Errorf("a file without a last newline: %q", ended)
|
|
}
|
|
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
|
|
t.Errorf("an empty file: %q", empty)
|
|
}
|
|
}
|
|
|
|
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
|
|
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
|
|
t.Errorf("%q", after)
|
|
}
|
|
}
|
|
|
|
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
|
|
for _, c := range []struct {
|
|
address string
|
|
names []string
|
|
says string
|
|
}{
|
|
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
|
|
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
|
|
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
|
|
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
|
|
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
|
|
{"192.0.2.9", []string{}, "at least one name"},
|
|
} {
|
|
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%q %q: %v", c.address, c.names, err)
|
|
}
|
|
}
|
|
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
|
|
t.Errorf("an IPv6 address and a rooted name: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
|
|
one, n, err := WithRemoved(file, "a.example.com")
|
|
if err != nil || n != 1 {
|
|
t.Fatalf("%d %v", n, err)
|
|
}
|
|
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
|
|
t.Errorf("%q", one)
|
|
}
|
|
if !reflect.DeepEqual(blocks(one), blocks(file)) {
|
|
t.Errorf("blocks changed")
|
|
}
|
|
all, n, err := WithRemoved(file, "127.0.0.1")
|
|
if err != nil || n != 2 {
|
|
t.Fatalf("%d %v", n, err)
|
|
}
|
|
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)) {
|
|
t.Errorf("blocks changed")
|
|
}
|
|
}
|
|
|
|
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 hostname.own") {
|
|
t.Errorf("%v", err)
|
|
}
|
|
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
|
|
t.Errorf("%d %v", n, err)
|
|
}
|
|
if _, _, err := WithRemoved(file, "bad name"); err == nil {
|
|
t.Errorf("a name that is neither an address nor a host name is refused")
|
|
}
|
|
}
|
|
|
|
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
|
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
|
|
t.Errorf("%s %v", p, a)
|
|
}
|
|
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
|
|
t.Errorf("%s %v", p, a)
|
|
}
|
|
}
|
|
|
|
// runPlain runs a command as given, unescalated: the test's file is the test's own.
|
|
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
|
|
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "hosts")
|
|
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var calls [][]string
|
|
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
|
calls = append(calls, append([]string{name}, args...))
|
|
return runPlain(ctx, name, args...)
|
|
}}
|
|
ctx := context.Background()
|
|
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
|
|
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
|
|
t.Fatalf("%+v %v", added, err)
|
|
}
|
|
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)
|
|
}
|
|
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
|
|
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
|
|
}
|
|
got, err := h.Entries()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
last := got.Lines[len(got.Lines)-1]
|
|
if last.Owner != Operator || last.Address != "192.0.2.9" {
|
|
t.Errorf("add then entries shows the line as the operator's: %+v", last)
|
|
}
|
|
removed, err := h.Remove(ctx, "lab.test")
|
|
if err != nil || removed.Removed != 1 {
|
|
t.Fatalf("%+v %v", removed, err)
|
|
}
|
|
if b, _ := os.ReadFile(path); string(b) != file {
|
|
t.Errorf("add then remove gives the file back: %q", b)
|
|
}
|
|
if _, err := os.Stat(beside); !os.IsNotExist(err) {
|
|
t.Errorf("the staged copy beside the file is left: %v", err)
|
|
}
|
|
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
|
|
t.Errorf("mode %v", info.Mode())
|
|
}
|
|
}
|
|
|
|
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
|
|
raw, err := os.ReadFile("../../module.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var m struct {
|
|
Claims []struct {
|
|
Name string `json:"name"`
|
|
Serves []string `json:"serves"`
|
|
} `json:"claims"`
|
|
Resources []struct {
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
} `json:"resources"`
|
|
}
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := false
|
|
for _, r := range m.Resources {
|
|
if r.ID == "own" {
|
|
found = true
|
|
if r.Path != HostsPath {
|
|
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("no resource own")
|
|
}
|
|
var served []string
|
|
for _, tool := range tools(HostsFile{}) {
|
|
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
|
|
if tool.Description == "" || tool.Run == nil {
|
|
t.Errorf("%s", tool.Name)
|
|
}
|
|
}
|
|
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
|
|
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
|
|
}
|
|
}
|
|
|
|
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
|
|
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
|
|
t.Errorf("%v", got)
|
|
}
|
|
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
|
|
t.Errorf("%v", got)
|
|
}
|
|
if got := namesArg(map[string]any{}); len(got) != 0 {
|
|
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")
|
|
}
|