The console gains live views of nodes, GPUs, models, replicas, usage and agent logs, and a settings tree over every key of server.yaml (agent.yaml on a node), generated from the configuration structs: values with their defaults and doc comments, edited in dialogs, saved only once the service's own loader accepts them, comments kept. The generator now tells blocks apart by package, which brings the TLS, engine and supervisor settings back into the reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
174 lines
5.6 KiB
Go
174 lines
5.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
|
)
|
|
|
|
func press(m tea.Model, keys ...string) tea.Model {
|
|
for _, k := range keys {
|
|
var msg tea.KeyMsg
|
|
switch k {
|
|
case "enter":
|
|
msg = tea.KeyMsg{Type: tea.KeyEnter}
|
|
case "tab":
|
|
msg = tea.KeyMsg{Type: tea.KeyTab}
|
|
case "down":
|
|
msg = tea.KeyMsg{Type: tea.KeyDown}
|
|
case "esc":
|
|
msg = tea.KeyMsg{Type: tea.KeyEsc}
|
|
case "right":
|
|
msg = tea.KeyMsg{Type: tea.KeyRight}
|
|
default:
|
|
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}
|
|
}
|
|
m, _ = m.Update(msg)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestTheSetupAsksForWhatHighAvailabilityNeedsAndRefusesWhatWillNotDo(t *testing.T) {
|
|
m := tea.Model(newSetupModel("ctl", []string{"10.0.0.5"}))
|
|
m = press(m, "down", "enter")
|
|
if s := m.(setupModel); s.page != pAdmin {
|
|
t.Fatalf("high availability did not lead to the administrator: page %d", s.page)
|
|
}
|
|
// No email: refused, with a reason.
|
|
m = press(m, "tab", "tab", "enter")
|
|
if s := m.(setupModel); s.page != pAdmin || !strings.Contains(s.formErr, "email") {
|
|
t.Fatalf("went on without an administrator: %+v", s.formErr)
|
|
}
|
|
// The refusal is a dialog; a key closes it, and the cursor is back on
|
|
// the email.
|
|
if !strings.Contains(m.View(), "Not yet") {
|
|
t.Error("the refusal was not shown as a dialog")
|
|
}
|
|
m = press(m, "x", "a@example.com", "tab", "tab", "enter")
|
|
s := m.(setupModel)
|
|
if s.page != pMachines {
|
|
t.Fatalf("page %d: %s", s.page, s.formErr)
|
|
}
|
|
// This machine's own address is not a database machine.
|
|
s.db[0].addr.SetValue("10.0.0.5")
|
|
s.db[0].user.SetValue("ubuntu")
|
|
s.db[0].auth = authAgent
|
|
s.db[1].addr.SetValue("10.0.0.7")
|
|
s.db[1].user.SetValue("ubuntu")
|
|
s.db[1].auth = authPassword
|
|
if err := s.validateMachines(); !strings.Contains(err, "this machine") {
|
|
t.Errorf("this machine as a database machine: %q", err)
|
|
}
|
|
s.db[0].addr.SetValue("10.0.0.6")
|
|
if err := s.validateMachines(); !strings.Contains(err, "password") {
|
|
t.Errorf("a password login with no password: %q", err)
|
|
}
|
|
s.db[1].secret.SetValue("pw")
|
|
if err := s.validateMachines(); err != "" {
|
|
t.Errorf("a valid pair refused: %s", err)
|
|
}
|
|
cmd := s.command()
|
|
for _, want := range []string{"--admin a@example.com", "--patroni ubuntu@10.0.0.6", "--patroni ubuntu@10.0.0.7", "--no-node"} {
|
|
if !strings.Contains(cmd, want) {
|
|
t.Errorf("command lacks %q:\n%s", want, cmd)
|
|
}
|
|
}
|
|
if strings.Contains(cmd, "pw") {
|
|
t.Error("the password is in the command line")
|
|
}
|
|
}
|
|
|
|
func TestChoosingOneMachineHandsOverToInit(t *testing.T) {
|
|
m := press(tea.Model(newSetupModel("ctl", nil)), "enter").(setupModel)
|
|
if !m.single {
|
|
t.Error("one machine did not hand over")
|
|
}
|
|
}
|
|
|
|
func TestTheChecksHoldTheSetupBackOnlyForAFailure(t *testing.T) {
|
|
m := newSetupModel("ctl", nil)
|
|
m.choice, m.page = 1, pCheck
|
|
m.report = preflight.Report{Findings: []preflight.Finding{{Machine: "a", Check: "disk", Level: preflight.Fail, Fix: "more"}}}
|
|
if s := press(m, "enter").(setupModel); s.page != pCheck {
|
|
t.Error("went on past a failed check")
|
|
}
|
|
m.report.Findings[0].Level = preflight.Warn
|
|
if s := press(m, "enter").(setupModel); s.page != pPlan {
|
|
t.Error("a warning held it back")
|
|
}
|
|
}
|
|
|
|
func TestTheConsoleOffersToReplicateOnlyAStoreThatIsNot(t *testing.T) {
|
|
m := consoleModel{}
|
|
if !m.entryOK(cReplicate) {
|
|
t.Error("a single store is not offered replication")
|
|
}
|
|
m.data.sum.Store = []adminapi.StoreMember{{Name: "a", Role: "leader"}}
|
|
if m.entryOK(cReplicate) {
|
|
t.Error("a replicated store is offered replication")
|
|
}
|
|
// A node is asked for in a dialog, then the console closes with it to do.
|
|
var mm tea.Model = consoleModel{menu: cAddNode, loaded: true, width: 100, height: 30}
|
|
mm = press(mm, "enter", "ubuntu@10.0.0.9", "enter")
|
|
if a := mm.(consoleModel).action; a.kind != "node" || a.targets[0] != "ubuntu@10.0.0.9" {
|
|
t.Errorf("%+v", a)
|
|
}
|
|
}
|
|
|
|
func TestTheConsoleWatchesTheClusterAndEditsItsSettings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file := filepath.Join(dir, "server.yaml")
|
|
if err := os.WriteFile(file, []byte("# mine\nlisten: \":8080\"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m := newConsoleModel("127.0.0.1:1", file)
|
|
m.width, m.height, m.loaded = 120, 40, true
|
|
m.data.nodes = []adminapi.NodeView{{ID: "cuda01", State: "ready", GPUTotal: 1, GPUs: []adminapi.GPUView{{Index: 0, Name: "Quadro RTX 6000"}}}}
|
|
m.data.replicas = []adminapi.ReplicaView{{ID: "r1", Model: "coder-14b", State: "ready",
|
|
Shards: []adminapi.ShardView{{Node: "cuda01", GPUs: []int{0}}}}}
|
|
|
|
// Watching: the GPUs view says which model holds each card.
|
|
m.menu = cGPUs
|
|
if v := m.View(); !strings.Contains(v, "Quadro RTX 6000") || !strings.Contains(v, "coder-14b") {
|
|
t.Errorf("the GPUs view:\n%s", v)
|
|
}
|
|
|
|
// Settings: into router, change retries, save.
|
|
var mm tea.Model = m
|
|
mm.(consoleModel).View()
|
|
cm := mm.(consoleModel)
|
|
cm.menu, cm.inPanel = cSettings, true
|
|
for i, r := range cm.settingsRows() {
|
|
if r.label == "router" {
|
|
cm.row = i
|
|
}
|
|
}
|
|
mm = press(cm, "enter") // into router
|
|
cm = mm.(consoleModel)
|
|
for i, r := range cm.settingsRows() {
|
|
if r.label == "retries" {
|
|
cm.row = i
|
|
}
|
|
}
|
|
if v := cm.View(); !strings.Contains(v, "(default)") {
|
|
t.Errorf("an unset key does not show its default:\n%s", v)
|
|
}
|
|
mm = press(cm, "enter") // the edit dialog
|
|
cm = mm.(consoleModel)
|
|
cm.prompt.SetValue("5")
|
|
mm = press(cm, "enter", "s")
|
|
if d := mm.(consoleModel).dialog; d != "saved" {
|
|
t.Fatalf("after saving the dialog is %q: %s", d, mm.(consoleModel).dialogText)
|
|
}
|
|
b, _ := os.ReadFile(file)
|
|
if !strings.Contains(string(b), "retries: 5") || !strings.Contains(string(b), "# mine") {
|
|
t.Errorf("server.yaml:\n%s", b)
|
|
}
|
|
}
|