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
385 lines
16 KiB
Go
385 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const searchOut = `extra/zsh 5.9.2-1 [installed]
|
|
A very advanced and programmable command interpreter (shell) for UNIX
|
|
extra/ripgrep 15.2.0-1 [installed: 15.1.0-1]
|
|
A search tool
|
|
core/base-devel 1-2 (base-devel)
|
|
Basic tools to build Arch Linux packages
|
|
`
|
|
|
|
func TestSearchReadsHeaderAndDescriptionAndWhatIsInstalled(t *testing.T) {
|
|
f := ParseSearch(searchOut)
|
|
if len(f) != 3 {
|
|
t.Fatalf("%+v", f)
|
|
}
|
|
if f[0].Repository != "extra" || f[0].Name != "zsh" || !f[0].Installed || f[0].InstalledAs != "5.9.2-1" || !strings.HasPrefix(f[0].Description, "A very advanced") {
|
|
t.Fatalf("%+v", f[0])
|
|
}
|
|
if f[1].InstalledAs != "15.1.0-1" || f[1].Version != "15.2.0-1" {
|
|
t.Fatalf("%+v", f[1])
|
|
}
|
|
if f[2].Installed || len(f[2].Groups) != 1 || f[2].Groups[0] != "base-devel" {
|
|
t.Fatalf("%+v", f[2])
|
|
}
|
|
}
|
|
|
|
func TestASearchThatFindsNothingIsEmptyAndAFailureIsAnError(t *testing.T) {
|
|
m := machine(fake(func(c call) Ran { return Ran{Status: 1} }, nil), 1000)
|
|
r, err := m.Search("nothing", 50)
|
|
if err != nil || r["count"] != 0 {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
m = machine(fake(func(c call) Ran { return Ran{Status: 1, Stderr: "error: failed to initialize alpm library\n"} }, nil), 1000)
|
|
if _, err := m.Search("x", 50); err == nil || !strings.Contains(err.Error(), "failed to initialize") {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
}
|
|
|
|
const infoOut = `Name : zsh
|
|
Version : 5.9.2-1
|
|
Depends On : pcre2 libcap gdbm
|
|
Optional Deps : grml-zsh-config: grml's zsh setup
|
|
zsh-doc: documentation [installed]
|
|
Required By : None
|
|
Install Reason : Explicitly installed
|
|
|
|
`
|
|
|
|
func TestInfoReadsListsAsListsAndFallsBackToTheRepositories(t *testing.T) {
|
|
p := ParseInfo(infoOut)
|
|
if len(p) != 1 {
|
|
t.Fatalf("%v", p)
|
|
}
|
|
if deps := p[0]["Depends On"].([]string); len(deps) != 3 || deps[2] != "gdbm" {
|
|
t.Fatalf("%v", p[0]["Depends On"])
|
|
}
|
|
if opt := p[0]["Optional Deps"].([]string); len(opt) != 2 || !strings.HasPrefix(opt[1], "zsh-doc") {
|
|
t.Fatalf("%v", p[0]["Optional Deps"])
|
|
}
|
|
if req := p[0]["Required By"].([]string); len(req) != 0 {
|
|
t.Fatalf("None is empty: %v", req)
|
|
}
|
|
var calls []call
|
|
m := machine(byLine(map[string]Ran{
|
|
"pacman -Qi -- zsh": {Status: 1, Stderr: "error: package 'zsh' was not found\n"},
|
|
"pacman -Si -- zsh": {Stdout: "Repository : extra\n" + infoOut},
|
|
}, &calls), 1000)
|
|
r, err := m.Info("zsh")
|
|
if err != nil || r["installed"] != false || r["package"].(map[string]any)["Repository"] != "extra" {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
}
|
|
|
|
func TestReadInstalledSaysWhyAndWhichRepositoryOrForeign(t *testing.T) {
|
|
m := machine(byLine(map[string]Ran{
|
|
"pacman -Q": {Stdout: "zsh 5.9-1\nglibc 2.42-1\nyay 12.0-1\n"},
|
|
"pacman -Qeq": {Stdout: "yay\nzsh\n"},
|
|
"pacman -Sl": {Stdout: "core glibc 2.42-1 [installed]\nextra zsh 5.9-1 [installed]\nextra-testing zsh 5.9-2\n"},
|
|
}, nil), 1000)
|
|
pk, skipped, err := m.ReadInstalled()
|
|
if err != nil || skipped != 0 {
|
|
t.Fatal(err, skipped)
|
|
}
|
|
want := []InstalledPackage{{"glibc", "2.42-1", false, "core"}, {"yay", "12.0-1", true, RepoForeign}, {"zsh", "5.9-1", true, "extra"}}
|
|
if !samePackages(pk, want) {
|
|
t.Fatalf("%+v", pk)
|
|
}
|
|
}
|
|
|
|
func TestOwnsAnswersNoOwnerAsAnAnswer(t *testing.T) {
|
|
m := machine(byLine(map[string]Ran{
|
|
"pacman -Qo -- /usr/bin/zsh": {Stdout: "/usr/bin/zsh is owned by zsh 5.9.2-1\n"},
|
|
"pacman -Qo -- /etc/hostname": {Status: 1, Stderr: "error: No package owns /etc/hostname\n"},
|
|
"pacman -Qo -- /nope": {Status: 1, Stderr: "error: failed to read file '/nope': No such file or directory\n"},
|
|
}, nil), 1000)
|
|
if r, err := m.Owns("/usr/bin/zsh"); err != nil || r["package"] != "zsh" || r["owned"] != true {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
if r, err := m.Owns("/etc/hostname"); err != nil || r["owned"] != false {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
if _, err := m.Owns("/nope"); err == nil {
|
|
t.Fatal("a path that is not there is an error")
|
|
}
|
|
if _, err := m.Owns("relative"); err == nil {
|
|
t.Fatal("a relative path was taken")
|
|
}
|
|
}
|
|
|
|
func TestUpdateListReadsCheckupdatesAndItsNothingToDo(t *testing.T) {
|
|
m := machine(byLine(map[string]Ran{"checkupdates": {Stdout: "zsh 5.9-1 -> 5.9-2\nlinux 6.1-1 -> 6.2-1\n"}}, nil), 1000)
|
|
r, err := m.UpdateList()
|
|
if err != nil || len(r) != 2 || r[0] != (VersionChange{"linux", "6.1-1", "6.2-1"}) {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
m = machine(byLine(map[string]Ran{"checkupdates": {Status: 2}}, nil), 1000)
|
|
if r, err := m.UpdateList(); err != nil || r == nil || len(r) != 0 {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
m = machine(byLine(map[string]Ran{"checkupdates": {Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}}, nil), 1000)
|
|
if _, err := m.UpdateList(); err == nil || !strings.Contains(err.Error(), "Cannot fetch updates") {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
}
|
|
|
|
func lockless(m *Machine) *Machine {
|
|
m.ReadFile = func(p string) ([]byte, error) { return nil, errNoFile }
|
|
return m
|
|
}
|
|
|
|
func TestAnUpgradesUnitIsReadBack(t *testing.T) {
|
|
unit := "mesh-pacman-upgrade-1791115200"
|
|
m := machine(byLine(map[string]Ran{
|
|
"systemctl show " + unit + " --no-pager --property=LoadState --property=ActiveState --property=SubState --property=Result --property=ExecMainStatus": {Stdout: "LoadState=loaded\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=1\n"},
|
|
"sudo -n journalctl --no-pager -o cat -n 60 -u " + unit: {Stdout: "error: failed to commit transaction (conflicting files)\n"},
|
|
}, nil), 1000)
|
|
r, err := m.UpgradeStatus(unit, 60)
|
|
if err != nil || r["finished"] != true || r["succeeded"] != false || r["exit_status"] != "1" || len(r["log"].([]string)) != 1 {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
if _, err := m.UpgradeStatus("sshd.service", 60); err == nil {
|
|
t.Fatal("a unit the tools did not start was read")
|
|
}
|
|
}
|
|
|
|
func orphanMachine(calls *[]call) *Machine {
|
|
return lockless(machine(fake(func(c call) Ran {
|
|
switch {
|
|
case c.String() == "pacman -Qdt":
|
|
return Ran{Stdout: "argon2 20190702-6\nclang21 21.1.8-1\n"}
|
|
case c.name == "sudo" && c.args[1] == "systemd-run":
|
|
return Ran{}
|
|
case c.name == "sudo" && c.args[1] == "journalctl":
|
|
return Ran{Stdout: "removing argon2...\n"}
|
|
}
|
|
return Ran{Status: 99}
|
|
}, calls), 1000))
|
|
}
|
|
|
|
func TestRemovingOrphansTakesOnlyOrphansNamedOrAll(t *testing.T) {
|
|
var calls []call
|
|
m := orphanMachine(&calls)
|
|
if _, err := m.RemoveOrphans(nil, false); err == nil || !strings.Contains(err.Error(), "name the orphans") {
|
|
t.Fatalf("nothing named: %v", err)
|
|
}
|
|
if _, err := m.RemoveOrphans([]string{"glibc"}, false); err == nil || !strings.Contains(err.Error(), "glibc is not an orphan") {
|
|
t.Fatalf("not an orphan: %v", err)
|
|
}
|
|
r, err := m.RemoveOrphans([]string{"argon2"}, false)
|
|
if err != nil || strings.Join(r["removed"].([]string), ",") != "argon2" {
|
|
t.Fatalf("%v %v", r, err)
|
|
}
|
|
var run string
|
|
for _, c := range calls {
|
|
if c.name == "sudo" && c.args[1] == "systemd-run" {
|
|
run = c.String()
|
|
}
|
|
}
|
|
if !strings.Contains(run, "--wait pacman -Rs --noconfirm -- argon2") {
|
|
t.Fatalf("ran: %s", run)
|
|
}
|
|
r, _ = m.RemoveOrphans(nil, true)
|
|
if len(r["removed"].([]string)) != 2 {
|
|
t.Fatalf("all: %v", r)
|
|
}
|
|
}
|
|
|
|
func TestCacheSaysWhatCleaningWouldFreeAndCleansThroughSudo(t *testing.T) {
|
|
var calls []call
|
|
m := machine(fake(func(c call) Ran {
|
|
switch c.String() {
|
|
case "find /var/cache/pacman/pkg -mindepth 1 -maxdepth 1 -printf %y %s %f\n":
|
|
return Ran{Status: 1, Stdout: "f 1000 zsh-5.9-1-x86_64.pkg.tar.zst\nf 10 zsh-5.9-1-x86_64.pkg.tar.zst.sig\nd 4096 download-abc\n", Stderr: "find: permission denied\n"}
|
|
case "paccache -d -k 3":
|
|
return Ran{Stdout: "\n==> finished dry run: 12 candidates (disk space saved: 1.5 GiB)\n"}
|
|
case "sudo -n paccache -r -k 3":
|
|
return Ran{Stdout: "==> finished: 12 packages removed (disk space saved: 1.5 GiB)\n"}
|
|
}
|
|
if c.name == "systemctl" {
|
|
return Ran{Stdout: "ActiveState=active\nUnitFileState=enabled\n"}
|
|
}
|
|
return Ran{Status: 99}
|
|
}, &calls), 1000)
|
|
c, err := m.Cache(3, false, false)
|
|
if err != nil || c.Files != 1 || c.Bytes != 1010 || c.LeftDownloads != 1 || c.Candidates != 12 || c.Frees != "1.5 GiB" || c.Cleaned {
|
|
t.Fatalf("%+v %v", c, err)
|
|
}
|
|
c, err = m.Cache(3, false, true)
|
|
if err != nil || !c.Cleaned || c.Candidates != 12 || c.Timer["UnitFileState"] != "enabled" {
|
|
t.Fatalf("%+v %v", c, err)
|
|
}
|
|
}
|
|
|
|
const pacmanLog = `[2026-09-24T17:47:36+0200] [PACMAN] starting full system upgrade
|
|
[2026-09-24T17:48:00+0200] [ALPM] upgraded linux (6.1-1 -> 6.2-1)
|
|
[2026-09-24T17:48:01+0200] [ALPM] installed zsh (5.9-1)
|
|
[2026-10-02T09:00:00+0200] [ALPM] removed ntp (4.2.8-1)
|
|
[2026-10-02T09:00:00+0200] [ALPM-SCRIPTLET] some words
|
|
[2022-01-01 10:00] [ALPM] installed old (1-1)
|
|
`
|
|
|
|
func TestHistoryReadsTheLogSinceADayAndTheLastFullUpgrade(t *testing.T) {
|
|
m := machine(nil, 1000)
|
|
m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil }
|
|
r, err := m.History("2026-09-01", "", "", 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev := r["events"].([]Event)
|
|
if len(ev) != 3 || ev[0].From != "6.1-1" || ev[0].Version != "6.2-1" || ev[2].Action != "removed" {
|
|
t.Fatalf("%+v", ev)
|
|
}
|
|
if r["last_full_upgrade"] != "2026-09-24T17:47:36+02:00" {
|
|
t.Fatalf("%v", r["last_full_upgrade"])
|
|
}
|
|
r, _ = m.History("2026-09-01", "removed", "", 10)
|
|
if r["count"] != 1 {
|
|
t.Fatalf("%v", r)
|
|
}
|
|
r, _ = m.History("2026-09-01", "", "", 1)
|
|
if r["truncated"] != true || r["events"].([]Event)[0].Package != "ntp" {
|
|
t.Fatalf("the newest are kept: %v", r)
|
|
}
|
|
if _, err := m.History("yesterday", "", "", 1); err == nil {
|
|
t.Fatal("not a day")
|
|
}
|
|
if _, err := m.History("", "exploded", "", 1); err == nil {
|
|
t.Fatal("not an action")
|
|
}
|
|
}
|
|
|
|
const feed = `<?xml version="1.0" encoding="utf-8"?>
|
|
<rss version="2.0"><channel><title>Arch Linux: Recent news updates</title>
|
|
<item><title>Manual intervention needed</title><link>https://example.org/news/a/</link><description><p>Do this <b>first</b>.</p></description><pubDate>Tue, 22 Sep 2026 09:09:27 +0000</pubDate></item>
|
|
<item><title>Old news</title><link>https://example.org/news/b/</link><description>old</description><pubDate>Mon, 01 Jun 2026 09:00:00 +0000</pubDate></item>
|
|
</channel></rss>`
|
|
|
|
func TestNewsSinceTheLastUpgrade(t *testing.T) {
|
|
items, err := ParseNews([]byte(feed), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
|
|
if err != nil || len(items) != 1 || items[0].Title != "Manual intervention needed" || items[0].Summary != "Do this first ." {
|
|
t.Fatalf("%+v %v", items, err)
|
|
}
|
|
fetch = func(string) ([]byte, error) { return []byte(feed), nil }
|
|
m := machine(nil, 1000)
|
|
m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil }
|
|
n := m.News("")
|
|
if n["reachable"] != true || n["since_last_full_upgrade"] != true || len(n["items"].([]NewsItem)) != 0 {
|
|
t.Fatalf("after the last upgrade on the 24th, the post of the 22nd is old: %v", n)
|
|
}
|
|
}
|
|
|
|
const mirrorlistReflector = `################################################################################
|
|
################# Arch Linux mirrorlist generated by Reflector #################
|
|
################################################################################
|
|
|
|
# With: reflector @/etc/xdg/reflector/reflector.conf
|
|
# When: 2024-06-12 21:26:34 UTC
|
|
|
|
Server = https://mirror.example.org/archlinux/$repo/os/$arch
|
|
Server = https://mirror2.example.org/$repo/os/$arch
|
|
#Server = https://old.example.org/$repo/os/$arch
|
|
`
|
|
|
|
// reflectorInfo is reflector's --info as it answered on 2026-11-01 (reflector 2023, its fields cut to those
|
|
// read and one more), the ranked order kept: two mirrors at the newest last sync, one behind, one with none.
|
|
const reflectorInfo = `https://nl.mirror.example/archlinux/$repo/os/$arch
|
|
active : True
|
|
country : Netherlands
|
|
last_sync : 2026-11-01 14:32:01 UTC
|
|
|
|
https://be.mirror.example/archlinux/$repo/os/$arch
|
|
active : True
|
|
last_sync : 2026-11-01 14:01:45 UTC
|
|
|
|
https://de.mirror.example/$repo/os/$arch
|
|
last_sync : 2026-11-01 14:32:01 UTC
|
|
|
|
https://fr.mirror.example/archlinux/$repo/os/$arch
|
|
last_sync : None
|
|
`
|
|
|
|
// ADR 0585 §2, §4: the ranking keeps only mirrors as fresh as the newest, in reflector's order, with the module's options
|
|
// and never its --save; a ranking that fails or keeps none is no list. pacman_mirrors answers the shared list, its
|
|
// ranker and age and whether this node's file matches it (the file as found when not); with refresh it asks this node's
|
|
// keeper to rank again, refused while a round runs, naming it.
|
|
func TestTheMeshsMirrorListIsRankedFreshAndAnsweredWithThisNodesFile(t *testing.T) {
|
|
conf := "# comment\n--save /etc/pacman.d/mirrorlist\n--country Belgium,Netherlands\n--sort rate\n"
|
|
if o := strings.Join(ReflectorOptions(conf), " "); o != "--country Belgium,Netherlands --sort rate" {
|
|
t.Errorf("the options are %q", o)
|
|
}
|
|
var asked []string
|
|
answer := Ran{Stdout: reflectorInfo}
|
|
saved := reflectorRun
|
|
reflectorRun = func(_ context.Context, args ...string) Ran { asked = args; return answer }
|
|
defer func() { reflectorRun = saved }()
|
|
l, err := RankMirrors([]byte(conf), "node-a", t0, "2026-W43")
|
|
if err != nil || strings.Join(l.Servers, " ") != "https://nl.mirror.example/archlinux/$repo/os/$arch https://de.mirror.example/$repo/os/$arch" ||
|
|
!l.Newest.Equal(time.Date(2026, 11, 1, 14, 32, 1, 0, time.UTC)) || l.RankedBy != "node-a" || l.Round != "2026-W43" ||
|
|
strings.Join(asked, " ") != "--country Belgium,Netherlands --sort rate --info" {
|
|
t.Fatalf("ranked %+v (%v), reflector asked %v", l, err, asked)
|
|
}
|
|
for _, a := range []Ran{{Status: 1, Stderr: "error: no mirrors"}, {Stdout: "https://x.example/$repo/os/$arch\nlast_sync : None\n"}} {
|
|
answer = a
|
|
if _, err := RankMirrors([]byte(conf), "node-a", t0, ""); err == nil {
|
|
t.Errorf("a ranking from %+v is a list", a)
|
|
}
|
|
}
|
|
if checkServers([]string{"https://ok.example/$repo/os/$arch\nServer = https://evil.example/$repo/os/$arch"}) == nil {
|
|
t.Error("a server carrying a second line is taken")
|
|
}
|
|
|
|
states := &fakeStates{}
|
|
states.store(StateMirrors).Put(MirrorsKey, l)
|
|
file := RenderMirrorlist(l.Servers)
|
|
m := machine(func(context.Context, string, ...string) Ran {
|
|
return Ran{Stdout: "LoadState=loaded\nUnitFileState=disabled\nActiveState=inactive\n"}
|
|
}, 1000)
|
|
m.Node, m.States = "node-a", readerOf(states)
|
|
m.Now = func() time.Time { return t0.Add(90 * time.Minute) }
|
|
m.ReadFile = func(string) ([]byte, error) { return file, nil }
|
|
v, err := m.MirrorsView(false)
|
|
if err != nil || v["file_matches"] != true || v["ranked_by"] != "node-a" || v["age"] != "1h30m0s" || v["file"] != nil ||
|
|
v["reflector_timer"].(map[string]string)["UnitFileState"] != "disabled" {
|
|
t.Fatalf("the view is %v (%v)", v, err)
|
|
}
|
|
file = []byte(mirrorlistReflector)
|
|
if v, _ := m.MirrorsView(false); v["file_matches"] != false || v["file"].(Mirrors).GeneratedBy != "reflector" {
|
|
t.Errorf("a file other than the list is %v", v)
|
|
}
|
|
|
|
rounds, runs, asks := newBucket(), newBucket(), newBucket()
|
|
savedState, savedNode := upgradeState, thisNode
|
|
upgradeState = func(name string) Bucket {
|
|
return map[string]Bucket{StateRounds: rounds, StateRuns: runs, StateAsks: asks}[name]
|
|
}
|
|
thisNode = func() string { return "node-a" }
|
|
defer func() { upgradeState, thisNode = savedState, savedNode }()
|
|
rounds.Put("2026-W43", Round{Round: "2026-W43", Opened: t0, OpenedBy: "node-a", Order: []string{"node-a", "node-b"}})
|
|
if _, err := m.MirrorsView(true); err == nil || !strings.Contains(err.Error(), "round 2026-W43 runs") {
|
|
t.Errorf("a refresh during a round: %v", err)
|
|
}
|
|
if keys, _ := asks.Keys(); len(keys) != 0 {
|
|
t.Errorf("a refused refresh asked %v", keys)
|
|
}
|
|
rounds.Delete("2026-W43")
|
|
v, err = m.MirrorsView(true)
|
|
if keys, _ := asks.Keys(); err != nil || len(keys) != 1 || !strings.HasPrefix(keys[0], "node-a.") || v["asked"] == nil {
|
|
t.Errorf("a refresh asked %v (%v): %v", keys, err, v)
|
|
}
|
|
}
|
|
|
|
func TestConfigIsReadAsPacmanConfResolvesIt(t *testing.T) {
|
|
c := ParseConf("[options]\nHoldPkg = pacman\nHoldPkg = glibc\nCheckSpace\nParallelDownloads = 5\n[core]\nUsage = All\nServer = https://a/core\nServer = https://b/core\n[extra]\nServer = https://a/extra\n")
|
|
if len(c.Options["HoldPkg"]) != 2 || c.Options["ParallelDownloads"][0] != "5" || len(c.Repositories) != 2 || c.Repositories[0].Servers != 2 || c.Repositories[0].FirstServer != "https://a/core" {
|
|
t.Fatalf("%+v", c)
|
|
}
|
|
}
|