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,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
|
||||
}
|
||||
Reference in New Issue
Block a user