From a8d308d440dc457b86799bb1f146da28cd72597a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:28:03 +0200 Subject: [PATCH] localization: locale, time zone and console keymap as one module One machine ran another time zone and a German console keymap with no record why. The module writes /etc/locale.conf and /etc/vconsole.conf whole and sets the zone through a run-once step of its own Go binary (timedatectl, read back): /etc/localtime is a link the mesh may not write (hq ADR 0012) and a module may not declare an action (ADR 0005). Tools: localization_get, _time_zone, _locales, _keymaps (to-be 42 Phase 1). --- modules/localization/README.md | 46 +++ .../cmd/localization-tools/localization.go | 340 ++++++++++++++++++ .../localization-tools/localization_test.go | 177 +++++++++ .../cmd/localization-tools/machine.go | 288 +++++++++++++++ .../cmd/localization-tools/machine_test.go | 106 ++++++ .../cmd/localization-tools/main.go | 106 ++++++ .../cmd/localization-tools/manifest_test.go | 53 +++ .../cmd/localization-tools/shape_test.go | 80 +++++ modules/localization/go.mod | 5 + modules/localization/go.sum | 2 + modules/localization/module.json | 56 +++ 11 files changed, 1259 insertions(+) create mode 100644 modules/localization/README.md create mode 100644 modules/localization/cmd/localization-tools/localization.go create mode 100644 modules/localization/cmd/localization-tools/localization_test.go create mode 100644 modules/localization/cmd/localization-tools/machine.go create mode 100644 modules/localization/cmd/localization-tools/machine_test.go create mode 100644 modules/localization/cmd/localization-tools/main.go create mode 100644 modules/localization/cmd/localization-tools/manifest_test.go create mode 100644 modules/localization/cmd/localization-tools/shape_test.go create mode 100644 modules/localization/go.mod create mode 100644 modules/localization/go.sum create mode 100644 modules/localization/module.json diff --git a/modules/localization/README.md b/modules/localization/README.md new file mode 100644 index 0000000..e78ae96 --- /dev/null +++ b/modules/localization/README.md @@ -0,0 +1,46 @@ +# localization + +Locale, time zone and console keymap as one module (novox/hq to-be 42 Phase 1, research 027: the +operator's choice of one module for the three). + +## What it owns + +- `/etc/locale.conf`, written whole: `LANG=en_US.UTF-8`. It takes effect at the next login. +- `/etc/vconsole.conf`, written whole: `KEYMAP=us`. It takes effect at the next boot. +- The time zone, `Europe/Brussels`, through a **step**. The host runs the module's own binary once + per version of the bundle, as root: `localization-tools set-time-zone Europe/Brussels`. The step + asks the time daemon (`timedatectl set-timezone`) only when the zone differs, and reads it back. + +## Why the time zone is a step + +`/etc/localtime` is a symbolic link into the zone database, and the mesh writes no symbolic links +(ADR 0012). A copy of the zone file written there works for the C library, but timedatectl and +everything else that reads the zone's *name* from the link then answers `n/a`. A module may not +declare an action (ADR 0005). The step makes no link itself: the distribution's own time daemon keeps +its link, and the step's answer is read back. + +The trade-off: the step runs again only when the bundle changes, not at every push. A zone changed +by hand stays changed until then. `localization_get` shows it as not as declared. + +## What it improves + +One machine was on another time zone (the same offset, a different name) with a German console +keymap, and nothing recorded why. Every machine is now the same. + +## What it leaves found + +- `/etc/locale.gen` and the generated locales. `en_US.UTF-8` was generated on all four machines on + 2026-10-04, so the module checks it (`lang_generated`) and does not generate it. +- X11's keyboard settings, which belong to the display server's module (research 026). + +On a machine whose `/etc/locale.conf` carried more than `LANG` (one workstation also had +`LANGUAGE=en_US`), the extra line goes. `LANG` alone means the same. + +## Tools + +| tool | | answers | +|---|---|---| +| `localization_get` | r | LANG and every `LC_*`, whether LANG is generated, the zone, whether the RTC keeps local time, NTP on and synchronised, the console keymap, X11 keyboard, and `as_declared` for each of the three | +| `localization_time_zone` | r/a | the zone; the zones matching a word; or `set` one (sudo -n timedatectl, read back) | +| `localization_locales` | r | generated, enabled in `locale.gen`, LANG and whether it is generated, and the locales glibc can generate (listed when narrowed) | +| `localization_keymaps` | r | the keymap in force and the keymaps available, narrowed to a word | diff --git a/modules/localization/cmd/localization-tools/localization.go b/modules/localization/cmd/localization-tools/localization.go new file mode 100644 index 0000000..e778d1f --- /dev/null +++ b/modules/localization/cmd/localization-tools/localization.go @@ -0,0 +1,340 @@ +package main + +// Locale, time zone and console keymap as one module (novox/hq to-be 42 Phase 1, research 027/02: +// the operator's choice of one module for the three). Measured on 2026-10-04, three machines had +// en_US.UTF-8, Europe/Brussels and no keymap, and one had another zone and a German keymap with no +// record why; the module brings every machine to the first. +// +// The locale and the keymap are files the host writes whole. The time zone is not a file the mesh +// may write: /etc/localtime is a symbolic link into the zone database, and the mesh creates no +// symbolic links (ADR 0012). Writing a copy of the zone there instead would leave every tool that +// reads the zone's name from the link (timedatectl among them) answering "n/a", and a module may not +// declare an action (ADR 0005). So the module's own binary is run once by the host, as root, as a +// step (`set-time-zone`, below): it asks the service manager's time daemon to set the zone, which +// makes the distribution's own link — the mesh writes none — and reads it back. + +import ( + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// What the module declares: its manifest's files and its step's argument, held to these by a test. +const ( + MeshLang = "en_US.UTF-8" + MeshZone = "Europe/Brussels" + MeshKeymap = "us" +) + +// Settings is the machine's locale, time zone and keymap as its own daemons report them. +type Settings struct { + Locale map[string]string `json:"locale"` + Lang string `json:"lang"` + LangGenerated bool `json:"lang_generated"` + TimeZone string `json:"time_zone"` + LocalRTC bool `json:"rtc_in_local_time"` + NTP bool `json:"ntp_enabled"` + NTPSynced bool `json:"ntp_synchronized"` + Keymap string `json:"console_keymap"` + X11 map[string]string `json:"x11,omitempty"` + // AsDeclared says, for each of the three, whether the machine is what the module declares. + AsDeclared map[string]bool `json:"as_declared"` +} + +// Get reads localectl and timedatectl, and whether the locale in force is generated. +func (m *Machine) Get() (Settings, error) { + s := Settings{Locale: map[string]string{}, X11: map[string]string{}} + out, err := m.Out("localectl", "status") + if err != nil { + return s, err + } + lc := ParseLocalectl(out) + s.Locale, s.Keymap, s.X11 = lc.Locale, lc.Keymap, lc.X11 + s.Lang = s.Locale["LANG"] + td, err := m.Out("timedatectl", "show") + if err != nil { + return s, err + } + kv := keyValues(td, "=") + s.TimeZone = kv["Timezone"] + s.LocalRTC = kv["LocalRTC"] == "yes" + s.NTP = kv["NTP"] == "yes" + s.NTPSynced = kv["NTPSynchronized"] == "yes" + gen, err := m.Out("locale", "-a") + if err != nil { + return s, err + } + s.LangGenerated = generated(lines(gen), s.Lang) + s.AsDeclared = map[string]bool{ + "locale": s.Lang == MeshLang && s.LangGenerated, + "time_zone": s.TimeZone == MeshZone, + "keymap": s.Keymap == MeshKeymap, + } + return s, nil +} + +// Localectl is `localectl status` read: the system locale's variables, the console keymap and the +// X11 keyboard settings. +type Localectl struct { + Locale map[string]string + Keymap string + X11 map[string]string +} + +// ParseLocalectl reads `localectl status`. The locale's variables continue on lines of their own +// beneath its label; "(unset)" is said as empty. +func ParseLocalectl(out string) Localectl { + l := Localectl{Locale: map[string]string{}, X11: map[string]string{}} + label := "" + for _, raw := range strings.Split(out, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + value := line + if k, v, ok := strings.Cut(line, ": "); ok && !strings.Contains(k, "=") { + label, value = strings.TrimSpace(k), strings.TrimSpace(v) + } + if value == "(unset)" || value == "n/a" { + value = "" + } + switch { + case label == "System Locale": + if k, v, ok := strings.Cut(value, "="); ok { + l.Locale[k] = v + } + case label == "VC Keymap": + l.Keymap = value + case strings.HasPrefix(label, "X11 "): + if value != "" { + l.X11[strings.ToLower(strings.TrimPrefix(label, "X11 "))] = value + } + } + } + return l +} + +// normal is a locale's name as glibc compares it: the codeset lowercased without dashes, so +// en_US.UTF-8 in a file and en_US.utf8 in `locale -a` are the same locale. +func normal(name string) string { + lang, codeset, ok := strings.Cut(name, ".") + if !ok { + return name + } + mod := "" + if c, at, found := strings.Cut(codeset, "@"); found { + codeset, mod = c, "@"+at + } + return lang + "." + strings.ToLower(strings.ReplaceAll(codeset, "-", "")) + mod +} + +func generated(have []string, want string) bool { + if want == "" { + return false + } + for _, h := range have { + if normal(strings.TrimSpace(h)) == normal(want) { + return true + } + } + return false +} + +// Zone is the time zone tool's answer. +type Zone struct { + TimeZone string `json:"time_zone"` + Before string `json:"before,omitempty"` + Changed bool `json:"changed"` + Declared string `json:"declared"` + Zones []string `json:"zones,omitempty"` + Count int `json:"zones_matching,omitempty"` + Note string `json:"note,omitempty"` +} + +func (m *Machine) zone() (string, error) { + out, err := m.Out("timedatectl", "show", "--property=Timezone", "--value") + return strings.TrimSpace(out), err +} + +func (m *Machine) zones() ([]string, error) { + out, err := m.Out("timedatectl", "list-timezones") + return lines(out), err +} + +// TimeZone reads the zone, lists the zones matching a word, or sets one. Setting goes through the +// time daemon with sudo -n, which polkit would otherwise refuse to an account without a session. +func (m *Machine) TimeZone(set, match string) (Zone, error) { + z := Zone{Declared: MeshZone} + current, err := m.zone() + if err != nil { + return z, err + } + z.TimeZone = current + if match != "" { + all, err := m.zones() + if err != nil { + return z, err + } + for _, name := range all { + if strings.Contains(strings.ToLower(name), strings.ToLower(match)) { + z.Zones = append(z.Zones, name) + } + } + z.Count = len(z.Zones) + if len(z.Zones) > 200 { + z.Zones = z.Zones[:200] + } + } + if set == "" { + return z, nil + } + all, err := m.zones() + if err != nil { + return z, err + } + if !contains(all, set) { + return z, fmt.Errorf("%q is not a time zone this machine knows (timedatectl list-timezones)", set) + } + z.Before = current + if set != current { + if _, err := m.Root("timedatectl", "set-timezone", set); err != nil { + return z, err + } + after, err := m.zone() + if err != nil { + return z, err + } + if after != set { + return z, fmt.Errorf("the time zone was set to %s and reads back as %s", set, after) + } + z.TimeZone, z.Changed = after, true + } + if set != MeshZone { + z.Note = fmt.Sprintf("the module declares %s; its step sets that zone again whenever the module's bundle changes", MeshZone) + } + return z, nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} + +// Locales is what this machine can, may and does use. +type Locales struct { + Lang string `json:"lang"` + LangGenerated bool `json:"lang_generated"` + Generated []string `json:"generated"` + Enabled []string `json:"enabled_in_locale_gen"` + Available []string `json:"available,omitempty"` + AvailableCount int `json:"available_count"` +} + +// Locales reads `locale -a`, the uncommented lines of /etc/locale.gen, and the locales glibc can +// generate (/usr/share/i18n/SUPPORTED) — listed when a word narrows them, counted otherwise. +func (m *Machine) Locales(match string) (Locales, error) { + l := Locales{Generated: []string{}, Enabled: []string{}} + gen, err := m.Out("locale", "-a") + if err != nil { + return l, err + } + l.Generated = lines(gen) + if conf, err := m.ReadFile("/etc/locale.conf"); err == nil { + l.Lang = keyValues(string(conf), "=")["LANG"] + } else if !os.IsNotExist(err) { + return l, err + } + l.LangGenerated = generated(l.Generated, l.Lang) + if gen, err := m.ReadFile("/etc/locale.gen"); err == nil { + for _, line := range lines(string(gen)) { + if line = strings.TrimSpace(line); !strings.HasPrefix(line, "#") { + l.Enabled = append(l.Enabled, line) + } + } + } + supported, err := m.ReadFile("/usr/share/i18n/SUPPORTED") + if err != nil && !os.IsNotExist(err) { + return l, err + } + for _, line := range lines(string(supported)) { + name := strings.Fields(line)[0] + l.AvailableCount++ + if match != "" && strings.Contains(strings.ToLower(name), strings.ToLower(match)) { + l.Available = append(l.Available, strings.TrimSpace(line)) + } + } + return l, nil +} + +// Keymaps is the console keymaps this machine has. +type Keymaps struct { + Current string `json:"current"` + Keymaps []string `json:"keymaps"` + Count int `json:"count"` +} + +var keymapName = regexp.MustCompile(`^[A-Za-z0-9_.+-]+$`) + +// Keymaps lists `localectl list-keymaps`, narrowed to a word when one is given. +func (m *Machine) Keymaps(match string) (Keymaps, error) { + k := Keymaps{Keymaps: []string{}} + status, err := m.Out("localectl", "status") + if err != nil { + return k, err + } + k.Current = ParseLocalectl(status).Keymap + out, err := m.Out("localectl", "list-keymaps", "--no-pager") + if err != nil { + return k, err + } + for _, name := range lines(out) { + name = strings.TrimSpace(name) + if !keymapName.MatchString(name) { + continue + } + if match == "" || strings.Contains(strings.ToLower(name), strings.ToLower(match)) { + k.Keymaps = append(k.Keymaps, name) + } + } + sort.Strings(k.Keymaps) + k.Count = len(k.Keymaps) + if len(k.Keymaps) > 500 { + k.Keymaps = k.Keymaps[:500] + } + return k, nil +} + +// SetTimeZoneStep is the module's step, run once by the host as root: the zone set through the time +// daemon when it differs, and read back. It changes nothing on a machine already in the zone. +func (m *Machine) SetTimeZoneStep(zone string) (string, error) { + if zone == "" || strings.HasPrefix(zone, "-") || strings.Contains(zone, "..") { + return "", fmt.Errorf("%q is not a time zone", zone) + } + if _, err := m.ReadFile("/usr/share/zoneinfo/" + zone); err != nil { + return "", fmt.Errorf("%s is not in this machine's zone database: %v", zone, err) + } + current, err := m.zone() + if err != nil { + return "", err + } + if current == zone { + return fmt.Sprintf("the time zone is already %s", zone), nil + } + if _, err := m.Root("timedatectl", "set-timezone", zone); err != nil { + return "", err + } + after, err := m.zone() + if err != nil { + return "", err + } + if after != zone { + return "", fmt.Errorf("the time zone was set to %s and reads back as %s", zone, after) + } + return fmt.Sprintf("the time zone was %s and is now %s", current, zone), nil +} diff --git a/modules/localization/cmd/localization-tools/localization_test.go b/modules/localization/cmd/localization-tools/localization_test.go new file mode 100644 index 0000000..4718b97 --- /dev/null +++ b/modules/localization/cmd/localization-tools/localization_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "strings" + "testing" +) + +const localectlLaptop = `System Locale: LANG=en_US.UTF-8 + LANGUAGE=en_US + VC Keymap: (unset) + X11 Layout: (unset) +` + +const localectlAnchor = `System Locale: LANG=en_US.UTF-8 + LC_TIME=nl_BE.UTF-8 + VC Keymap: de-latin1-nodeadkeys + X11 Layout: de + X11 Model: pc105 +` + +func TestLocalectlIsReadWithItsContinuationLinesAndUnsetAsEmpty(t *testing.T) { + l := ParseLocalectl(localectlLaptop) + if l.Locale["LANG"] != "en_US.UTF-8" || l.Locale["LANGUAGE"] != "en_US" || l.Keymap != "" || len(l.X11) != 0 { + t.Fatalf("%+v", l) + } + a := ParseLocalectl(localectlAnchor) + if a.Locale["LC_TIME"] != "nl_BE.UTF-8" || a.Keymap != "de-latin1-nodeadkeys" || a.X11["layout"] != "de" || a.X11["model"] != "pc105" { + t.Fatalf("%+v", a) + } +} + +func TestALocaleIsGeneratedWhateverTheCodesetsSpelling(t *testing.T) { + have := []string{"C", "C.utf8", "POSIX", "en_US.utf8", "nl_BE.utf8@euro"} + if !generated(have, "en_US.UTF-8") || generated(have, "de_DE.UTF-8") || generated(have, "") || !generated(have, "nl_BE.UTF-8@euro") { + t.Fatal("generated") + } +} + +func getMachine(zone, keymapStatus string) *Machine { + return machine(byLine(map[string]Ran{ + "localectl status": {Stdout: keymapStatus}, + "timedatectl show": {Stdout: "Timezone=" + zone + "\nLocalRTC=no\nCanNTP=yes\nNTP=yes\nNTPSynchronized=yes\n"}, + "locale -a": {Stdout: "C\nC.utf8\nPOSIX\nen_US.utf8\n"}, + }, nil), 1000) +} + +func TestGetSaysWhetherEachOfTheThreeIsAsDeclared(t *testing.T) { + s, err := getMachine("Europe/Berlin", localectlAnchor).Get() + if err != nil { + t.Fatal(err) + } + if s.TimeZone != "Europe/Berlin" || !s.NTP || !s.NTPSynced || s.LocalRTC || s.Keymap != "de-latin1-nodeadkeys" || !s.LangGenerated { + t.Fatalf("%+v", s) + } + if !s.AsDeclared["locale"] || s.AsDeclared["time_zone"] || s.AsDeclared["keymap"] { + t.Fatalf("as declared: %v", s.AsDeclared) + } + s, _ = getMachine("Europe/Brussels", strings.Replace(localectlLaptop, "VC Keymap: (unset)", "VC Keymap: us", 1)).Get() + if !s.AsDeclared["locale"] || !s.AsDeclared["time_zone"] || !s.AsDeclared["keymap"] { + t.Fatalf("as declared: %v", s.AsDeclared) + } +} + +func zoneMachine(zones *[]string, calls *[]call) *Machine { + current := "Europe/Berlin" + return machine(fake(func(c call) Ran { + switch c.String() { + case "timedatectl show --property=Timezone --value": + return Ran{Stdout: current + "\n"} + case "timedatectl list-timezones": + return Ran{Stdout: strings.Join(*zones, "\n") + "\n"} + case "sudo -n timedatectl set-timezone Europe/Brussels", "timedatectl set-timezone Europe/Brussels": + current = "Europe/Brussels" + return Ran{} + case "sudo -n timedatectl set-timezone Europe/Paris": + current = "Europe/Paris" + return Ran{} + } + return Ran{Status: 99, Stderr: "unexpected: " + c.String()} + }, calls), 1000) +} + +func TestSettingTheZoneEscalatesIsReadBackAndRefusesAnUnknownZone(t *testing.T) { + zones := []string{"Europe/Berlin", "Europe/Brussels", "Europe/Paris"} + var calls []call + m := zoneMachine(&zones, &calls) + z, err := m.TimeZone("Europe/Brussels", "") + if err != nil || !z.Changed || z.Before != "Europe/Berlin" || z.TimeZone != "Europe/Brussels" || z.Note != "" { + t.Fatalf("%+v %v", z, err) + } + if _, err := m.TimeZone("Mars/Olympus", ""); err == nil || !strings.Contains(err.Error(), "not a time zone this machine knows") { + t.Fatalf("an unknown zone: %v", err) + } + z, err = m.TimeZone("Europe/Paris", "") + if err != nil || !strings.Contains(z.Note, "declares Europe/Brussels") { + t.Fatalf("another zone than the declared one is said: %+v %v", z, err) + } + z, _ = m.TimeZone("", "bru") + if z.Count != 1 || z.Zones[0] != "Europe/Brussels" || z.Changed { + t.Fatalf("match: %+v", z) + } +} + +func TestTheStepSetsTheZoneOnlyWhenItDiffersAsRoot(t *testing.T) { + zones := []string{"Europe/Brussels"} + var calls []call + m := zoneMachine(&zones, &calls) + m.UID = 0 + m.ReadFile = func(p string) ([]byte, error) { + if p == "/usr/share/zoneinfo/Europe/Brussels" { + return []byte("TZif"), nil + } + return nil, errNoFile + } + said, err := m.SetTimeZoneStep("Europe/Brussels") + if err != nil || said != "the time zone was Europe/Berlin and is now Europe/Brussels" { + t.Fatalf("%q %v", said, err) + } + for _, c := range calls { + if c.name == "sudo" { + t.Fatal("the step runs as root and does not go through sudo") + } + } + calls = nil + said, err = m.SetTimeZoneStep("Europe/Brussels") + if err != nil || !strings.Contains(said, "already") { + t.Fatalf("%q %v", said, err) + } + for _, c := range calls { + if strings.Contains(c.String(), "set-timezone") { + t.Fatal("a machine already in the zone was set again") + } + } + if _, err := m.SetTimeZoneStep("Nowhere/Here"); err == nil { + t.Fatal("a zone not in the database was set") + } + if _, err := m.SetTimeZoneStep("../etc"); err == nil { + t.Fatal("a path was taken for a zone") + } +} + +func TestLocalesAreListedAndAvailableOnesOnlyWhenNarrowed(t *testing.T) { + files := map[string]string{ + "/etc/locale.conf": "LANG=en_US.UTF-8\n", + "/etc/locale.gen": "# en_US.UTF-8 UTF-8\nen_US.UTF-8 UTF-8 \n#nl_BE.UTF-8 UTF-8\n", + "/usr/share/i18n/SUPPORTED": "en_US.UTF-8 UTF-8\nen_US ISO-8859-1\nnl_BE.UTF-8 UTF-8\n", + } + m := machine(byLine(map[string]Ran{"locale -a": {Stdout: "C\nen_US.utf8\n"}}, nil), 1000) + m.ReadFile = func(p string) ([]byte, error) { + if s, ok := files[p]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + l, err := m.Locales("") + if err != nil { + t.Fatal(err) + } + if l.Lang != "en_US.UTF-8" || !l.LangGenerated || len(l.Enabled) != 1 || l.AvailableCount != 3 || l.Available != nil { + t.Fatalf("%+v", l) + } + l, _ = m.Locales("nl_") + if len(l.Available) != 1 || l.Available[0] != "nl_BE.UTF-8 UTF-8" { + t.Fatalf("%+v", l.Available) + } +} + +func TestKeymapsAreNarrowedAndTheCurrentOneSaid(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "localectl status": {Stdout: localectlAnchor}, + "localectl list-keymaps --no-pager": {Stdout: "be-latin1\nde-latin1\nde-latin1-nodeadkeys\nus\n"}, + }, nil), 1000) + k, err := m.Keymaps("de") + if err != nil || k.Current != "de-latin1-nodeadkeys" || k.Count != 2 { + t.Fatalf("%+v %v", k, err) + } +} diff --git a/modules/localization/cmd/localization-tools/machine.go b/modules/localization/cmd/localization-tools/machine.go new file mode 100644 index 0000000..5e41de7 --- /dev/null +++ b/modules/localization/cmd/localization-tools/machine.go @@ -0,0 +1,288 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// Escalated is the command as it is run: as given when this process is root, else through sudo +// without a prompt. +func Escalated(uid int, name string, args ...string) (string, []string) { + if uid == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/localization/cmd/localization-tools/machine_test.go b/modules/localization/cmd/localization-tools/machine_test.go new file mode 100644 index 0000000..b400f46 --- /dev/null +++ b/modules/localization/cmd/localization-tools/machine_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/localization/cmd/localization-tools/main.go b/modules/localization/cmd/localization-tools/main.go new file mode 100644 index 0000000..20d0b86 --- /dev/null +++ b/modules/localization/cmd/localization-tools/main.go @@ -0,0 +1,106 @@ +// localization's tools bundle (novox/hq to-be 42 Phase 1, research 026/05), and its step. +// +// Served by the node's runtime over MCP on stdio through the Go SDK (ADR 0188, ADR 0193) when it is +// started with no arguments. Started as `localization-tools set-time-zone ` it is instead the +// module's step, which the host runs once as root for every version of the bundle (localization.go +// says why the time zone is a step and not a file). +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "localization-tools" + +func bg() context.Context { return context.Background() } + +func main() { + m := ThisMachine() + if len(os.Args) > 1 { + if len(os.Args) != 3 || os.Args[1] != "set-time-zone" { + fmt.Fprintf(os.Stderr, "usage: %s [set-time-zone ]\n", binaryName) + os.Exit(2) + } + said, err := m.SetTimeZoneStep(os.Args[2]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println(said) + return + } + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): localization. + if err := stdio.Serve("", tools(m)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "localization_get", + Description: "The machine's locale (LANG and every LC_* localed reports), whether LANG is generated, its time zone, " + + "whether the clock keeps local time, whether NTP is on and synchronised, the console keymap and the X11 keyboard " + + "settings — and for each of locale, zone and keymap whether it is what the module declares " + + "(en_US.UTF-8, Europe/Brussels, us).", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Get() }, + }, + { + Name: "localization_time_zone", + Description: "The time zone: read it; list the zones matching a word (match); or set one (set), through the time " + + "daemon with sudo -n, read back after. The module declares Europe/Brussels and its step sets it again " + + "whenever the module's bundle changes, which the answer says when another zone is set.", + Input: schema(map[string]any{ + "set": map[string]any{"type": "string", "description": "a zone to set, as timedatectl list-timezones names it (optional)"}, + "match": map[string]any{"type": "string", "description": "list the zones whose name holds this word (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + set, err := text(args, "set", false) + if err != nil { + return nil, err + } + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + return m.TimeZone(set, match) + }, + }, + { + Name: "localization_locales", + Description: "The locales: generated (locale -a), enabled in /etc/locale.gen, the LANG of /etc/locale.conf and " + + "whether it is generated, and how many glibc can generate — listed when a word narrows them (match).", + Input: schema(map[string]any{ + "match": map[string]any{"type": "string", "description": "list the generatable locales whose name holds this word (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + return m.Locales(match) + }, + }, + { + Name: "localization_keymaps", + Description: "The console keymap in force and the keymaps this machine has (localectl list-keymaps), narrowed to a word when given; at most 500 listed.", + Input: schema(map[string]any{ + "match": map[string]any{"type": "string", "description": "list only keymaps whose name holds this word (optional)"}, + }), + Run: func(args map[string]any) (any, error) { + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + return m.Keymaps(match) + }, + }, + } +} diff --git a/modules/localization/cmd/localization-tools/manifest_test.go b/modules/localization/cmd/localization-tools/manifest_test.go new file mode 100644 index 0000000..27edf2f --- /dev/null +++ b/modules/localization/cmd/localization-tools/manifest_test.go @@ -0,0 +1,53 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, research 027): two files written whole and the +// time zone as a step of its own binary — never a symbolic link written by the mesh (ADR 0012), +// never an action (ADR 0005). + +import ( + "strings" + "testing" +) + +func TestTheFilesSayWhatTheToolsCompareAgainst(t *testing.T) { + m := manifest(t) + if m.Module != "localization" || m.Version != "1" { + t.Fatalf("%s %s", m.Module, m.Version) + } + locale := m.resource(t, "locale") + if locale["path"] != "/etc/locale.conf" || locale["into"] != nil || !strings.Contains(locale["content"].(string), "\nLANG="+MeshLang+"\n") { + t.Fatalf("locale: %v", locale) + } + keymap := m.resource(t, "keymap") + if keymap["path"] != "/etc/vconsole.conf" || !strings.Contains(keymap["content"].(string), "\nKEYMAP="+MeshKeymap+"\n") { + t.Fatalf("keymap: %v", keymap) + } + for _, r := range []resource{locale, keymap} { + var settings []string + for _, l := range strings.Split(r["content"].(string), "\n") { + if l != "" && !strings.HasPrefix(l, "#") { + settings = append(settings, l) + } + } + if len(settings) != 1 { + t.Fatalf("%v says one thing: %v", r["id"], settings) + } + } +} + +func TestTheTimeZoneIsAStepOfTheModulesOwnBinaryRunAsRoot(t *testing.T) { + m := manifest(t) + step := m.resource(t, "time-zone") + if step["type"] != "process" || step["run-once"] != true || step["artifact"] != "tools" || step["user"] != nil { + t.Fatalf("step: %v", step) + } + run := step["run"].([]any) + if len(run) != 3 || run[0] != "./"+binaryName || run[1] != "set-time-zone" || run[2] != MeshZone { + t.Fatalf("run: %v", run) + } + for _, r := range m.Resources { + if r["type"] == "action" || r["path"] == "/etc/localtime" { + t.Fatalf("%v: the zone is never written by the mesh", r["id"]) + } + } +} diff --git a/modules/localization/cmd/localization-tools/shape_test.go b/modules/localization/cmd/localization-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/localization/cmd/localization-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/localization/go.mod b/modules/localization/go.mod new file mode 100644 index 0000000..4802d44 --- /dev/null +++ b/modules/localization/go.mod @@ -0,0 +1,5 @@ +module localization + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/localization/go.sum b/modules/localization/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/localization/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/localization/module.json b/modules/localization/module.json new file mode 100644 index 0000000..020b902 --- /dev/null +++ b/modules/localization/module.json @@ -0,0 +1,56 @@ +{ + "module": "localization", + "version": "1", + "capabilities": [ + "service-manager" + ], + "tools": [ + "localization_get", + "localization_time_zone", + "localization_locales", + "localization_keymaps" + ], + "resources": [ + { + "id": "locale", + "type": "file", + "path": "/etc/locale.conf", + "mode": "0644", + "content": "# The mesh's (module localization, novox/hq to-be 42): the system locale. Written whole at every\n# push; an edit here is overwritten. Read at the next login.\nLANG=en_US.UTF-8\n" + }, + { + "id": "keymap", + "type": "file", + "path": "/etc/vconsole.conf", + "mode": "0644", + "content": "# The mesh's (module localization, novox/hq to-be 42): the console keymap. Written whole at every\n# push; an edit here is overwritten. Read at the next boot.\nKEYMAP=us\n" + }, + { + "id": "time-zone", + "type": "process", + "name": "localization-time-zone", + "artifact": "tools", + "run": [ + "./localization-tools", + "set-time-zone", + "Europe/Brussels" + ], + "run-once": true + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/localization-tools", + "binary": "localization-tools", + "loads": [ + "localization-tools" + ] + } + ] + } +}