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…
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
434 lines
17 KiB
Go
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
|
|
}
|