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).
This commit is contained in:
@@ -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 |
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 `key<sep>value` 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <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 <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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module localization
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user