package main // Runs on the bus and the log read once (novox/hq ADR 0508 ยง1; acceptance 1841/AC1 for the writing of steps, // 1841/AC2 for the log and pacman_upgrades). import ( "errors" "os" "strings" "testing" "time" stdio "git.novox.be/novox/mesh-sdk/go" ) func capturedLog(t *testing.T) string { t.Helper() raw, err := os.ReadFile("testdata/pacman-log-three-full-upgrades/pacman.log") if err != nil { t.Fatal(err) } return string(raw) } // The captured log holds three full upgrades among other commands: one that ended before any transaction, one // whose transaction completed, and a later one that upgraded one package. func TestEveryFullUpgradeOfTheLogIsARunReadFromIt(t *testing.T) { runs := PastUpgrades("node-a", capturedLog(t)) if len(runs) != 3 { t.Fatalf("%d runs, want the three full upgrades and none of the other commands: %+v", len(runs), runs) } for _, r := range runs { if r.Node != "node-a" || r.Kind != RunUpgrade || r.From != FromLog || len(r.Steps) != 2 || r.Steps[0].Step != StepStarted { t.Errorf("a run read from the log: %+v", r) } } first, second, third := runs[0], runs[1], runs[2] if first.Run != "20261004T130648Z" || first.Steps[1].Step != StepFinished || first.Steps[1].Why != noTransaction || len(first.Steps[1].Changes.Changed) != 0 { t.Errorf("an upgrade with no transaction says so: %+v", first) } end := second.Steps[1] if second.Run != "20261004T130724Z" || end.Step != StepFinished || !end.At.Equal(time.Date(2026, 10, 4, 13, 22, 50, 0, time.UTC)) { t.Errorf("the second upgrade finished when its transaction completed: %+v", second) } var names []string for _, c := range end.Changes.Changed { names = append(names, c.Name) } if strings.Join(names, " ") != "bash glibc iana-etc readline tzdata wireshark-qt xfsprogs xpad xterm zathura-pdf-mupdf" { t.Errorf("what it changed, sorted: %v", names) } if c := end.Changes.Changed[0]; c.From != "5.3.15-1" || c.To != "5.3.20-1" { t.Errorf("bash: %+v", c) } if len(end.Changes.Removed) != 0 { t.Errorf("the removal run between them is no part of an upgrade: %+v", end.Changes.Removed) } if c := third.Steps[1].Changes.Changed; third.Run != "20261004T132817Z" || len(c) != 1 || c[0].Name != "dunst" { t.Errorf("the third upgrade: %+v", third) } } func TestATransactionWithoutAnEndOrThatFailedIsAFailedRun(t *testing.T) { log := "[2026-10-10T04:00:00+0000] [PACMAN] Running 'pacman -Syu --noconfirm'\n" + "[2026-10-10T04:00:01+0000] [PACMAN] starting full system upgrade\n" + "[2026-10-10T04:00:05+0000] [ALPM] transaction started\n" + "[2026-10-10T04:00:06+0000] [ALPM] upgraded linux (7.2.8.arch1-2 -> 7.2.9.arch1-1)\n" + "[2026-10-10T04:00:09+0000] [ALPM] transaction failed\n" + "[2026-10-11T04:00:00+0000] [PACMAN] Running 'pacman -Syu --noconfirm'\n" + "[2026-10-11T04:00:01+0000] [PACMAN] starting full system upgrade\n" + "[2026-10-11T04:00:05+0000] [ALPM] transaction started\n" runs := PastUpgrades("node-b", log) if len(runs) != 2 { t.Fatalf("%+v", runs) } if s := runs[0].Steps[1]; s.Step != StepFailed || s.Error != "transaction failed" || len(s.Changes.Changed) != 1 { t.Errorf("a failed transaction: %+v", s) } if s := runs[1].Steps[1]; s.Step != StepFailed || !strings.Contains(s.Error, "no end") { t.Errorf("a transaction the log holds no end of: %+v", s) } } func TestTheLogIsReadIntoRunsOnceAndASecondStartReadsNothing(t *testing.T) { m := machine(nil, 1000) reads := 0 log := capturedLog(t) m.ReadFile = func(path string) ([]byte, error) { if path != PacmanLog { t.Fatalf("read %s", path) } reads++ return []byte(log), nil } runs := newBucket() other := Run{Node: "node-b", Run: "20261001T040000Z", Kind: RunUpgrade, From: FromRound} if _, err := runs.Create(NodeKey("node-b", other.Run), other); err != nil { t.Fatal(err) } u := &Upgrades{Node: "node-a", Machine: m, Runs: runs} n, err := u.ImportLog() if err != nil || n != 3 || reads != 1 { t.Fatalf("first start: %d runs, %d reads, %v", n, reads, err) } var r Run if !runs.value("node-a.20261004T132817Z", &r) || r.From != FromLog { t.Errorf("the run is kept at ., marked as read from the log: %+v", r) } before := len(runs.writes) n, err = u.ImportLog() if err != nil || n != 0 || reads != 1 || len(runs.writes) != before { t.Errorf("a second start: %d runs, %d reads, %d writes, %v", n, reads, len(runs.writes)-before, err) } } // A run's key is created at its first step and then only updated from the revision its writer holds: a step // is written once, and a run another writer changed meanwhile is refused, never overwritten. func TestAStepIsWrittenOnceAndAnotherWritersChangeIsNeverOverwritten(t *testing.T) { runs := newBucket() u := &Upgrades{Node: "node-a", Runs: runs} r := &openRun{Run: Run{Node: "node-a", Run: "20261024T040000Z", Kind: RunUpgrade, From: FromAsk}} at := time.Date(2026, 10, 24, 4, 0, 0, 0, time.UTC) for _, s := range []string{StepRecorded, StepCheckedBefore} { if err := u.step(r, Step{Step: s, At: at}); err != nil { t.Fatal(err) } } if w := strings.Join(runs.writes, ", "); w != "create node-a.20261024T040000Z, update node-a.20261024T040000Z" { t.Errorf("writes: %s", w) } var kept Run runs.value("node-a.20261024T040000Z", &kept) if len(kept.Steps) != 2 || kept.Steps[1].Step != StepCheckedBefore { t.Errorf("kept: %+v", kept) } if _, err := runs.Put("node-a.20261024T040000Z", kept); err != nil { t.Fatal(err) } if err := u.step(r, Step{Step: StepStarted, At: at}); !errors.Is(err, stdio.ErrChanged) || len(r.Steps) != 2 { t.Errorf("a step over another writer's change: %v, %d steps held", err, len(r.Steps)) } } func TestPacmanUpgradesAnswersAllOneNodeDaysAndOneRun(t *testing.T) { runs := newBucket() put := func(node, id string, steps ...Step) { r := Run{Node: node, Run: id, Kind: RunUpgrade, From: FromRound, Round: "2026-W43", Steps: steps} if _, err := runs.Create(NodeKey(node, id), r); err != nil { t.Fatal(err) } } at := time.Date(2026, 10, 24, 4, 30, 0, 0, time.UTC) passed := &Check{Passed: true} put("node-a", "20261024T040000Z", Step{Step: StepRecorded, At: at}, Step{Step: StepFinished, At: at}, Step{Step: StepCheckedAfter, At: at, Check: passed}) put("node-b", "20261024T050000Z", Step{Step: StepRecorded, At: at}, Step{Step: StepStarted, At: at}) put("node-b", "20261017T050000Z", Step{Step: StepRefused, At: at, Why: "installed is not current"}) all, err := ListUpgrades(runs, "", "", "", "", 50) if err != nil { t.Fatal(err) } listed := all["runs"].([]RunSummary) var order []string for _, s := range listed { order = append(order, s.Node+"/"+s.Run+"/"+s.Outcome+"/"+s.Checked) } if strings.Join(order, " ") != "node-b/20261024T050000Z/running/ node-a/20261024T040000Z/finished/passed node-b/20261017T050000Z/refused/" { t.Fatalf("all runs, newest first: %v", order) } if listed[2].Error != "installed is not current" { t.Errorf("a refused run says why: %+v", listed[2]) } one, _ := ListUpgrades(runs, "node-b", "", "", "", 50) if one["count"] != 2 { t.Errorf("one node: %v", one) } days, _ := ListUpgrades(runs, "", "2026-10-18", "2026-10-24", "", 50) if days["count"] != 2 { t.Errorf("between two days, the last one included: %v", days) } limited, _ := ListUpgrades(runs, "", "", "", "", 1) if limited["count"] != 1 || limited["truncated"] != true { t.Errorf("limited: %v", limited) } whole, err := ListUpgrades(runs, "node-a", "", "", "20261024T040000Z", 50) if err != nil || len(whole["run"].(Run).Steps) != 3 { t.Errorf("one run whole: %v %v", whole, err) } if _, err := ListUpgrades(runs, "node-a", "", "", "20261024T050000Z", 50); err == nil { t.Error("a run the node does not have was answered") } if _, err := ListUpgrades(runs, "", "", "", "20261024T040000Z", 50); err == nil { t.Error("a run without its node was answered") } } func TestTheUpgradesToolReadsTheRunsState(t *testing.T) { runs := newBucket() saved := upgradeState upgradeState = func(name string) Bucket { if name != StateRuns { t.Fatalf("read %s", name) } return runs } defer func() { upgradeState = saved }() for _, tool := range tools(machine(nil, 1000)) { if tool.Name == "pacman_upgrades" { out, err := tool.Run(map[string]any{"node": "node-a"}) if err != nil || out.(map[string]any)["count"] != 0 { t.Errorf("%v %v", out, err) } return } } t.Fatal("no pacman_upgrades") }