Files
mesh-catalog/modules/pacman/cmd/pacman-tools/mirrors.go
T
jschoubben 3d1a450406
mesh/delivery-group group feat/node-packages rejected: composed together: the checker's verdict at the members' heads (98ce55efa892): unit: i3/cmd/i3-tools.Tes…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: i3/cmd/i3-tools.TestAtItsStartTheWatcherReloadsWhatTheRunningI3HasNotLoaded: loaded map[/tmp/TestAtItsStartTheWatcherReloadsW…
pacman starts a round's run only once the node's file holds the round's mirror list, and a refresh never writes after a round opened (ADR 0585 §2, §4; review round 1)
The opener ranked the list and took its turn in the same step, so its upgrade started
before the keeper's change hook wrote /etc/pacman.d/mirrorlist, and a failed write on
any node was only logged. A round's run now writes the list first and checks the file
holds the revision the round opened with; otherwise the run is refused with why and
tried again an hour later, as every refused run is. A refresh asks again for a running
round right before its write, since ranking takes minutes.

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

434 lines
17 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. guard, when set, is asked
// right before the write and refuses it with its error: ranking takes minutes, and what held when it began may not.
func (u *Upgrades) rankMirrors(round string, guard func() error) (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
}
if guard != nil {
if err := guard(); 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, nil)
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: asked before the ranking and again
// right before its write, so a refresh that began before a round opened never writes after the round's key exists.
func (u *Upgrades) takeMirrorsAsk() error {
noRound := func() error {
if round, err := runningRound(u.Rounds, u.Runs, u.Machine.Now()); err != nil {
return err
} else if round != "" {
return refreshRefused(round)
}
return nil
}
if err := noRound(); err != nil {
return err
}
_, err := u.rankMirrors("", noRound)
return err
}
// roundMirrorsWritten is why this node may not start its run in round yet, empty when it may: its mirror list file
// must hold the list the round opened with (Round.Mirrors), written now when it does not (ADR 0585 §2: every node of
// the round upgrades from that list). A round that opened with no list (none ranked yet) asks nothing of the file.
// A refusal is a refused run, tried again an hour later as every refused run of a round is.
func (u *Upgrades) roundMirrorsWritten(round string) string {
e, err := u.Rounds.Get(round)
if err != nil || e == nil {
return fmt.Sprintf("round %s cannot be read to know its mirror list: %v", round, err)
}
var r Round
if err := json.Unmarshal(e.Value, &r); err != nil {
return fmt.Sprintf("round %s is not readable: %v", round, err)
}
if r.Mirrors == 0 {
return ""
}
if u.Mirrors == nil {
return "this keeper reads no mirror list, and round " + round + " opened with one"
}
list, rev, err := readMirrorList(u.Mirrors)
switch {
case err != nil:
return "the mesh's mirror list cannot be read: " + err.Error()
case list == nil || rev != r.Mirrors:
return fmt.Sprintf("round %s opened with the mirror list at revision %d, and the bus holds revision %d", round, r.Mirrors, rev)
}
if _, err := u.WriteMirrors(); err != nil {
return "the round's mirror list is not written to " + Mirrorlist + ": " + err.Error()
}
if have, err := u.Machine.ReadFile(Mirrorlist); err != nil || !bytes.Equal(have, RenderMirrorlist(list.Servers)) {
return fmt.Sprintf("%s does not hold the mirror list round %s opened with (revision %d)", Mirrorlist, round, r.Mirrors)
}
return ""
}
// 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
}