Files
mesh-catalog/modules/pacman/cmd/pacman-tools/mirrors.go
T
jschoubben 2b495c4818 pacman keeps one mirror list for the mesh, ranked in the write that opens each round and written by every node (ADR 0585, issue 1876)
Each node's reflector ranked its own list on its own timer, and on 2026-10-31 one node's
lagging first mirror left it a Node.js version behind the rest. The list is now the
module's state mirrors, key list: the node that opens a week's round ranks it with the
module's reflector options, keeps only mirrors whose last sync is no older than the
newest found, and the round's key names the list's revision. Every keeper writes
/etc/pacman.d/mirrorlist whole from the key through the root helper's new verb
mirrorlist, when the key changes and when the file differs, and never writes an empty
or unreadable list. reflector.timer is declared stopped and disabled, and reflector.conf
saves nothing. pacman_mirrors answers the shared list, its ranker and age and whether
this node's file matches; with refresh it asks this node's keeper to rank again,
refused while a round runs, naming it. The round's integration test proves the opening
write and both nodes' files.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 16:18:22 +01:00

386 lines
15 KiB
Go

package main
// The mesh's one mirror list (novox/hq ADR 0585). Before it each node's reflector ranked its own list on its own weekly
// timer (to-be 42 Phase 1), and on 2026-10-31 one node's first mirror lagged, so its upgrade found nothing to install
// while the others moved on. Now:
//
// - the list is one key of the module's own state, `mirrors.list` (§1): the servers in order, when and by which node
// they were ranked, and the options;
// - it is ranked once per weekly round, by the node that opens it, inside the write that opens it (§2): reflector
// ranks with the module's options (reflector.conf, its `--save` left out), only the mirrors whose last sync is no
// older than the newest found are kept, and the round's key names the list's revision;
// - each node's keeper writes /etc/pacman.d/mirrorlist whole from the key, through the root helper, when the key
// changes and when the file differs (the keeper watches the file), so a hand edit comes back; a list that is
// empty or cannot be read is never written, and the node keeps its last (§3); reflector stays installed as the
// ranking tool, and its weekly timer is declared stopped and disabled;
// - `pacman_mirrors` answers the shared list, its age, its ranker and whether this node's file matches; with
// refresh it asks this node's keeper to rank again for the mesh, refused while a round runs, naming it (§4).
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Where the list is and where the module's reflector options are.
const (
Mirrorlist = "/etc/pacman.d/mirrorlist"
ReflectorConf = "/etc/xdg/reflector/reflector.conf"
)
// RankTimeout bounds one ranking: reflector downloads from every candidate mirror to rate it, which takes longer than
// a tool call may (CallTimeout), so the keeper ranks, never a call.
const RankTimeout = 3 * time.Minute
// mostMirrors bounds a list the root helper writes: far more than a ranking keeps (the module's --latest).
const mostMirrors = 100
// Mirrors is a mirror list file as found: the servers in force, those commented out, and the generator's header.
type Mirrors struct {
Servers []string `json:"servers"`
Commented int `json:"commented_servers"`
GeneratedBy string `json:"generated_by,omitempty"`
When string `json:"generated_when,omitempty"`
}
// mirrorlistHeader is the first line of the file the mesh writes, by which it is known as the mesh's.
const mirrorlistHeader = "# The mesh's mirror list (module pacman, novox/hq ADR 0585)"
// ParseMirrorlist reads the servers in force, those commented out, and the generator's header.
func ParseMirrorlist(text string) Mirrors {
m := Mirrors{Servers: []string{}}
for _, l := range lines(text) {
l = strings.TrimSpace(l)
switch {
case strings.HasPrefix(l, "Server"):
if _, v, ok := strings.Cut(l, "="); ok {
m.Servers = append(m.Servers, strings.TrimSpace(v))
}
case strings.HasPrefix(strings.TrimLeft(l, "# "), "Server"):
m.Commented++
case l == mirrorlistHeader:
m.GeneratedBy = "the mesh"
case strings.Contains(l, "generated by Reflector"):
m.GeneratedBy = "reflector"
case strings.HasPrefix(l, "# When:"):
m.When = strings.TrimSpace(strings.TrimPrefix(l, "# When:"))
case strings.HasPrefix(l, "## Generated on"):
m.GeneratedBy, m.When = "the distribution's mirrorlist", strings.TrimSpace(strings.TrimPrefix(l, "## Generated on"))
}
}
return m
}
// mirrorServer is a server line's value pacman takes from the mesh: http(s), a host, a path, and the repository and
// architecture left to pacman. Nothing else reaches the file: not a newline, not a second option.
var mirrorServer = regexp.MustCompile(`^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~%+@-]+)*/\$repo/os/\$arch$`)
// checkServers refuses a list that is empty, too long, or holds anything but a server.
func checkServers(servers []string) error {
if len(servers) == 0 {
return errors.New("the list is empty")
}
if len(servers) > mostMirrors {
return fmt.Errorf("the list holds %d servers, more than %d", len(servers), mostMirrors)
}
for _, s := range servers {
if !mirrorServer.MatchString(s) {
return fmt.Errorf("%q is not a mirror's server", s)
}
}
return nil
}
// RenderMirrorlist is the mirror list file whole, as every node writes it from the shared list: the same servers are
// the same bytes, so a node compares its file with the list by its bytes.
func RenderMirrorlist(servers []string) []byte {
var b bytes.Buffer
b.WriteString(mirrorlistHeader + "\n")
b.WriteString("# Written whole from the bus (the state mirrors of the module pacman): a hand edit is written back.\n")
b.WriteString("# pacman_mirrors says when it was ranked and by which node.\n")
for _, s := range servers {
b.WriteString("Server = " + s + "\n")
}
return b.Bytes()
}
// ReflectorOptions is the options reflector.conf sets, each line split into its flag and its value as reflector reads
// them: a `--save` is left out, since the keeper writes the file (ADR 0585 §3).
func ReflectorOptions(conf string) []string {
out := []string{}
for _, l := range lines(conf) {
l = strings.TrimSpace(l)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
flag, value, _ := strings.Cut(l, " ")
if flag == "--save" {
continue
}
out = append(out, flag)
if value = strings.Trim(strings.TrimSpace(value), `"'`); value != "" {
out = append(out, value)
}
}
return out
}
// rankedMirror is one mirror reflector ranked, in its order, with its last sync (zero when the status says none).
type rankedMirror struct {
URL string
LastSync time.Time
}
// reflectorTime is how reflector's --info writes a last sync.
const reflectorTime = "2006-01-02 15:04:05 MST"
// ParseReflectorInfo reads reflector's --info: one block per mirror in the order it ranked them, its server first and
// then `field : value` lines, of which last_sync is read.
func ParseReflectorInfo(text string) []rankedMirror {
var out []rankedMirror
open := false
for _, l := range strings.Split(text, "\n") {
l = strings.TrimSpace(l)
switch {
case l == "":
open = false
case !open && strings.Contains(l, "://") && !strings.Contains(l, " "):
out, open = append(out, rankedMirror{URL: l}), true
case open:
if k, v, ok := strings.Cut(l, ":"); ok && strings.TrimSpace(k) == "last_sync" {
if t, err := time.Parse(reflectorTime, strings.TrimSpace(v)); err == nil {
out[len(out)-1].LastSync = t.UTC()
}
}
}
}
return out
}
// Freshest keeps, in their order, only the mirrors whose last sync is no older than the newest found (ADR 0585 §2), so
// a mirror lagging behind the others is never in the list; one whose last sync is unknown is left out.
func Freshest(ranked []rankedMirror) (servers []string, newest time.Time) {
for _, m := range ranked {
if m.LastSync.After(newest) {
newest = m.LastSync
}
}
for _, m := range ranked {
if !m.LastSync.IsZero() && !m.LastSync.Before(newest) {
servers = append(servers, m.URL)
}
}
return servers, newest
}
// reflectorRun runs reflector with args on this machine, within RankTimeout; its cache in the temporary directory, since
// the keeper's account has no home of its own. A test replaces it.
var reflectorRun = func(ctx context.Context, args ...string) Ran {
return execWithin(ctx, RankTimeout, []string{"XDG_CACHE_HOME=" + os.TempDir()}, "reflector", args...)
}
// RankMirrors ranks the mirrors with reflector and the module's options (conf, reflector.conf's text) and keeps the
// freshest: the mesh's list, ranked by node at now for round (none for a refresh). A ranking that fails or keeps no
// mirror is an error, never an empty list (ADR 0585 §3).
func RankMirrors(conf []byte, node string, now time.Time, round string) (MirrorList, error) {
opts := ReflectorOptions(string(conf))
r := reflectorRun(context.Background(), append(append([]string(nil), opts...), "--info")...)
if r.Status != 0 || r.Err != "" {
return MirrorList{}, failure("reflector", "reflector", r)
}
ranked := ParseReflectorInfo(r.Stdout)
servers, newest := Freshest(ranked)
if err := checkServers(servers); err != nil {
return MirrorList{}, fmt.Errorf("reflector ranked no list the mesh can use (%d mirrors read): %w", len(ranked), err)
}
return MirrorList{Servers: servers, RankedAt: now.UTC(), RankedBy: node, Options: opts, Newest: newest, Round: round}, nil
}
// readMirrorList is the shared list and its revision; nil when there is none yet.
func readMirrorList(r interface {
Get(key string) (*stdio.StateEntry, error)
}) (*MirrorList, uint64, error) {
e, err := r.Get(MirrorsKey)
if err != nil || e == nil {
return nil, 0, err
}
var l MirrorList
if err := json.Unmarshal(e.Value, &l); err != nil {
return nil, e.Revision, fmt.Errorf("%s.%s is not readable: %w", StateMirrors, MirrorsKey, err)
}
return &l, e.Revision, nil
}
// ---- the keeper's part -------------------------------------------------------------------------------------------
// rankMirrors ranks the mesh's list and writes it to the shared key, answering its revision.
func (u *Upgrades) rankMirrors(round string) (uint64, error) {
if u.Rank == nil || u.Mirrors == nil {
return 0, errors.New("this keeper ranks no mirrors")
}
conf, err := u.Machine.ReadFile(ReflectorConf)
if err != nil {
return 0, fmt.Errorf("the module's reflector options cannot be read: %w", err)
}
list, err := u.Rank(conf, round)
if err != nil {
return 0, err
}
return u.Mirrors.Put(MirrorsKey, list)
}
// mirrorsForRound ranks the list in the write that opens round (ADR 0585 §2): the revision the round opens with, and
// why it was not ranked again when it was not, the round then opening with the list there is.
func (u *Upgrades) mirrorsForRound(round string) (uint64, string) {
rev, err := u.rankMirrors(round)
if err == nil {
return rev, ""
}
note := "the mirrors were not ranked again: " + err.Error()
if u.Mirrors != nil {
if _, kept, rerr := readMirrorList(u.Mirrors); rerr == nil && kept > 0 {
return kept, note + fmt.Sprintf("; the round opens with the list at revision %d", kept)
}
}
return 0, note + "; there is no shared list yet, and every node keeps its own"
}
// runningRound is the round that runs now, none when every node of the latest round passed: a refresh is refused
// while one runs, so every node of a round upgrades from the list it opened with (ADR 0585 §4).
func runningRound(rounds, runs Bucket, now time.Time) (string, error) {
id, err := latestRound(rounds)
if err != nil || id == "" {
return "", err
}
v, err := readRound(rounds, runs, id)
if err != nil || v == nil {
return "", err
}
for _, n := range RoundState(v, now, 0) {
if n.State != InRoundPassed {
return id, nil
}
}
return "", nil
}
// refreshRefused is the words of a refresh refused during round.
func refreshRefused(round string) error {
return fmt.Errorf("round %s runs: every node of it upgrades from the list it opened with, so the mirrors are ranked "+
"again only once every node of it passed (pacman_round says where it stands)", round)
}
// takeMirrorsAsk ranks the list again for the mesh on a refresh, unless a round runs.
func (u *Upgrades) takeMirrorsAsk() error {
if round, err := runningRound(u.Rounds, u.Runs, u.Machine.Now()); err != nil {
return err
} else if round != "" {
return refreshRefused(round)
}
_, err := u.rankMirrors("")
return err
}
// WriteMirrors writes this node's mirror list file whole from the shared list when they differ, through the root
// helper, answering whether it wrote. No list yet writes nothing; a list that is empty or cannot be read is never
// written, and the node keeps its last (ADR 0585 §3).
func (u *Upgrades) WriteMirrors() (bool, error) {
if u.Mirrors == nil {
return false, nil
}
list, _, err := readMirrorList(u.Mirrors)
if err != nil {
return false, fmt.Errorf("%v; this node keeps its mirror list", err)
}
if list == nil {
return false, nil
}
if err := checkServers(list.Servers); err != nil {
return false, fmt.Errorf("the shared mirror list is not written: %v; this node keeps its mirror list", err)
}
want := RenderMirrorlist(list.Servers)
if have, err := u.Machine.ReadFile(Mirrorlist); err == nil && bytes.Equal(have, want) {
return false, nil
}
if err := u.Root.Mirrorlist(list.Servers); err != nil {
return false, fmt.Errorf("writing %s: %w", Mirrorlist, err)
}
return true, nil
}
// ---- the tool -----------------------------------------------------------------------------------------------------
// MirrorsView answers pacman_mirrors (ADR 0585 §4): the shared list, its revision and age and who ranked it, and this
// node's file beside it; with refresh, the ask of this node's keeper to rank again for the mesh, refused while a round
// runs.
func (m *Machine) MirrorsView(refresh bool) (map[string]any, error) {
if err := m.states(); err != nil {
return nil, err
}
out := map[string]any{"node": m.Node}
list, rev, err := readMirrorList(m.States(StateMirrors))
if err != nil {
return nil, err
}
out["shared"], out["revision"] = list, rev
text, ferr := m.ReadFile(Mirrorlist)
if ferr != nil {
out["file_error"] = ferr.Error()
}
file := ParseMirrorlist(string(text))
if list == nil {
out["note"] = "no shared list yet: it is ranked when the next round opens, or by pacman_mirrors with refresh"
out["file"] = file
} else {
out["age"] = m.Now().Sub(list.RankedAt).Round(time.Minute).String()
out["ranked_by"] = list.RankedBy
matches := ferr == nil && bytes.Equal(text, RenderMirrorlist(list.Servers))
out["file_matches"] = matches
if !matches {
out["file"] = file
}
}
if t, err := m.unitProps("reflector.timer", "LoadState", "UnitFileState", "ActiveState"); err == nil {
out["reflector_timer"] = t
}
if !refresh {
return out, nil
}
now := m.Now()
round, err := runningRound(upgradeState(StateRounds), upgradeState(StateRuns), now)
if err != nil {
return nil, err
}
if round != "" {
return nil, refreshRefused(round)
}
a, err := AskMirrors(upgradeState(StateAsks), thisNode(), now)
if err != nil {
return nil, err
}
out["asked"] = a.ID
out["next"] = "the keeper of " + a.Node + " ranks the mirrors for the mesh now, which takes a minute or two; every " +
"node writes the new list once it lands, and pacman_mirrors says it"
return out, nil
}
// AskMirrors writes an ask for node's keeper to rank the mesh's mirrors again.
func AskMirrors(asks Bucket, node string, now time.Time) (Ask, error) {
if node == "" {
return Ask{}, errors.New("no node to ask: the runtime names none (MESH_NODE)")
}
a := Ask{ID: AskID(now), Node: node, Act: ActMirrors, At: now.UTC()}
if _, err := asks.Create(NodeKey(node, a.ID), a); err != nil {
return Ask{}, fmt.Errorf("writing the ask: %w", err)
}
return a, nil
}