In a terminal and without --admin, server init shows a short form: the administrator's email, the other names nodes will use (with the addresses it found), SQLite or PostgreSQL, and the mirror, node and starter model. Its own lines then fill in as a list — read from the writer init already uses, so nothing in init changed — with downloads updating in place and the password in a box until the end. The whole run, password included, is printed again when the screen closes, so it is in the terminal's history. The last screen says how to add a node and to back up the authority's key. node add takes several addresses and adds them in turn, with a summary. Suggested fabrics carry what the adapters report them to be, so a name given for one node is used for the next on the same fabric without asking. Tried on empty VMs: init from the form through to the starter model serving, and two nodes added to that cluster in one command. The first attempt at the form lost its keystrokes to the terminal colour query in a pseudo-terminal that never answers it; a real terminal does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
118 lines
4.1 KiB
Go
118 lines
4.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
|
)
|
|
|
|
func send(m tea.Model, msgs ...tea.Msg) tea.Model {
|
|
for _, msg := range msgs {
|
|
m, _ = m.Update(msg)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestTheNodeScreenFillsInAsStepsFinish(t *testing.T) {
|
|
var m tea.Model = newNodeModel("adding gpu01", func() {})
|
|
m = send(m,
|
|
evMsg{Step: onboard.StepConnect, Level: onboard.Done, Detail: "gpu01 (amd64)"},
|
|
evMsg{Step: onboard.StepCheck, Level: onboard.Warn, Detail: "fabric: nvidia_peermem is not loaded", Fix: "modprobe nvidia_peermem"},
|
|
evMsg{Step: onboard.StepCheck, Level: onboard.Running, Detail: "running llmgw doctor"},
|
|
)
|
|
v := m.View()
|
|
for _, want := range []string{"✓", "gpu01 (amd64)", "nvidia_peermem is not loaded", "→ modprobe nvidia_peermem", "running llmgw doctor", "engines"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("screen lacks %q:\n%s", want, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheFabricQuestionAnswersWithWhatWasTyped(t *testing.T) {
|
|
var m tea.Model = newNodeModel("adding gpu01", func() {})
|
|
reply := make(chan []onboard.Fabric, 1)
|
|
m = send(m, askFabricsMsg{suggested: []onboard.Fabric{
|
|
{Name: "fabric1", HCAs: []string{"mlx5_0", "mlx5_1"}},
|
|
{Name: "fabric2", HCAs: []string{"mlx5_4"}},
|
|
}, reply: reply})
|
|
if v := m.View(); !strings.Contains(v, "Which fabric") || !strings.Contains(v, "mlx5_0, mlx5_1") {
|
|
t.Fatalf("no question on screen:\n%s", m.View())
|
|
}
|
|
// Rename the first to ib-a, empty the second to leave it out.
|
|
for range len("fabric1") {
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyBackspace})
|
|
}
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("ib-a")}, tea.KeyMsg{Type: tea.KeyTab})
|
|
for range len("fabric2") {
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyBackspace})
|
|
}
|
|
m = send(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
got := <-reply
|
|
if len(got) != 1 || got[0].Name != "ib-a" || strings.Join(got[0].HCAs, ",") != "mlx5_0,mlx5_1" {
|
|
t.Errorf("got %+v", got)
|
|
}
|
|
if strings.Contains(m.View(), "Which fabric") {
|
|
t.Error("the question stayed on screen after it was answered")
|
|
}
|
|
}
|
|
|
|
func TestContinuingPastAProblemIsNoUnlessSaidYes(t *testing.T) {
|
|
var m tea.Model = newNodeModel("adding gpu01", func() {})
|
|
reply := make(chan bool, 1)
|
|
m = send(m, askContinueMsg{fails: 2, reply: reply}, tea.KeyMsg{Type: tea.KeyEnter})
|
|
if <-reply {
|
|
t.Error("enter went on past the problems")
|
|
}
|
|
m = send(m, askContinueMsg{fails: 2, reply: reply}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
if !<-reply {
|
|
t.Error("y did not go on")
|
|
}
|
|
_ = m
|
|
}
|
|
|
|
func TestANodeCountsAsJoinedOnlyWhenHeardSinceItsAgentStarted(t *testing.T) {
|
|
start := time.Now().Add(-10 * time.Second)
|
|
cases := []struct {
|
|
state, ago string
|
|
want bool
|
|
}{
|
|
{"ready", "3s", true},
|
|
{"cordoned", "3s", true}, // added again while cordoned
|
|
{"ready", "30s", false}, // the agent before the restart
|
|
{"unreachable", "3s", false},
|
|
{"ready", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
if got := heardSince(adminapi.NodeView{State: c.state, LastSeenAgo: c.ago}, start); got != c.want {
|
|
t.Errorf("%s seen %q ago: got %v", c.state, c.ago, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAFabricNamedForOneNodeIsNotAskedAgainForTheNext(t *testing.T) {
|
|
na := &nodeAdd{named: map[string]string{}}
|
|
asked := 0
|
|
ask := func(s []onboard.Fabric) []onboard.Fabric {
|
|
asked++
|
|
return []onboard.Fabric{{Name: "ib-compute", HCAs: s[0].HCAs, Key: s[0].Key}}
|
|
}
|
|
first := na.rememberFabrics(ask)([]onboard.Fabric{{Name: "fabric", HCAs: []string{"mlx5_0"}, Key: "sm:0xa"}})
|
|
second := na.rememberFabrics(ask)([]onboard.Fabric{{Name: "fabric", HCAs: []string{"mlx5_0", "mlx5_1"}, Key: "sm:0xa"}})
|
|
if asked != 1 {
|
|
t.Errorf("asked %d times", asked)
|
|
}
|
|
if len(second) != 1 || second[0].Name != "ib-compute" || len(second[0].HCAs) != 2 || first[0].Name != "ib-compute" {
|
|
t.Errorf("first %+v second %+v", first, second)
|
|
}
|
|
// A fabric not seen before is asked about; in a script nothing is asked.
|
|
if got := na.rememberFabrics(nil)([]onboard.Fabric{{Name: "fabric", Key: "sm:0xb"}}); len(got) != 0 {
|
|
t.Errorf("a script was given a fabric nobody named: %+v", got)
|
|
}
|
|
}
|