server add gives another machine this llmgw, a certificate, the configuration and store address, lets it into the store through Patroni, and starts it; --vip keeps a floating address on the leader with keepalived, following a new /leader endpoint. Nodes learn every control plane from the leader and notice a dead one by HTTP/2 ping instead of after the kernel's fifteen minutes. The setup, console, node and init screens are drawn full screen in one frame: title bar, sidebar, bordered panel, key bar, dialogs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
950 lines
26 KiB
Go
950 lines
26 KiB
Go
package cli
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/mail"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
joinpkg "gitea.mynodes.xyz/ilker/llmgw/internal/join"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||
)
|
||
|
||
// The setup screens: what a bare `llmgw` shows on a machine with nothing
|
||
// installed. One machine, or three for high availability — this one as the
|
||
// control plane and etcd's third vote, two more for the store under Patroni —
|
||
// with every machine checked before anything is installed on any of them.
|
||
//
|
||
// The other machines are reached with Go's own ssh rather than the system's,
|
||
// so a password can be typed here when there is no key; it is held in memory
|
||
// for the run and written nowhere. A sudo password, likewise, is used once to
|
||
// let the user sudo for the length of the install, and that is taken back
|
||
// when it finishes.
|
||
|
||
type setupPage int
|
||
|
||
const (
|
||
pWelcome setupPage = iota
|
||
pAdmin
|
||
pMachines
|
||
// pWait shows the codes for machines joining rather than logged into,
|
||
// until they have all dialled in.
|
||
pWait
|
||
pCheck
|
||
pPlan
|
||
pRun
|
||
)
|
||
|
||
// How a database machine is logged into.
|
||
const (
|
||
authAgent = iota
|
||
authKey
|
||
authPassword
|
||
// authJoin is no login at all: the machine runs `llmgw join` with a code
|
||
// shown here and dials out to this one.
|
||
authJoin
|
||
authCount
|
||
)
|
||
|
||
var authNames = [authCount]string{"ssh agent", "key file", "password", "join code"}
|
||
|
||
// Fields of one database machine, in order.
|
||
const (
|
||
mfAddr = iota
|
||
mfUser
|
||
mfAuth
|
||
mfSecret
|
||
mfSudo
|
||
mfCount
|
||
)
|
||
|
||
type dbMachine struct {
|
||
addr, user, secret, sudo textField
|
||
auth int
|
||
// For a machine joining: its code, and it once it has dialled in.
|
||
code string
|
||
joined *joinpkg.Remote
|
||
arrive <-chan *joinpkg.Remote
|
||
}
|
||
|
||
// visible reports whether a field is shown, and so can be moved to: a joining
|
||
// machine is asked nothing but how it joins.
|
||
func (d dbMachine) visible(f int) bool {
|
||
switch {
|
||
case d.auth == authJoin:
|
||
return f == mfAuth
|
||
case f == mfSecret:
|
||
return d.auth != authAgent
|
||
}
|
||
return true
|
||
}
|
||
|
||
// dbConn is a database machine as it is reached, whichever way.
|
||
type dbConn struct {
|
||
remote onboard.Remote
|
||
addr string
|
||
ssh *onboard.GoSSH
|
||
joined *joinpkg.Remote
|
||
}
|
||
|
||
func newDBMachine(n int) dbMachine {
|
||
f := func(ph string, mask bool) textField {
|
||
t := newTextField()
|
||
t.Placeholder, t.Mask, t.CharLimit, t.Width = ph, mask, 200, 34
|
||
return t
|
||
}
|
||
m := dbMachine{
|
||
addr: f(fmt.Sprintf("10.0.0.%d or db%d.internal", 10+n, n), false),
|
||
user: f("ubuntu", false),
|
||
secret: f("", true),
|
||
sudo: f("only if sudo asks for one", true),
|
||
}
|
||
if os.Getenv("SSH_AUTH_SOCK") == "" {
|
||
m.auth = authKey
|
||
}
|
||
return m
|
||
}
|
||
|
||
// field is the text field at a machine's position, nil for the selector.
|
||
func (d *dbMachine) field(i int) *textField {
|
||
switch i {
|
||
case mfAddr:
|
||
return &d.addr
|
||
case mfUser:
|
||
return &d.user
|
||
case mfSecret:
|
||
return &d.secret
|
||
case mfSudo:
|
||
return &d.sudo
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Messages from the work behind the screens.
|
||
type (
|
||
checkDoneMsg struct{ rep preflight.Report }
|
||
joinedMsg struct {
|
||
i int
|
||
r *joinpkg.Remote
|
||
}
|
||
)
|
||
|
||
// progRef lets work started from Update send to the program it runs in.
|
||
type progRef struct{ p *tea.Program }
|
||
|
||
type setupModel struct {
|
||
ref *progRef
|
||
|
||
page setupPage
|
||
host string
|
||
ips []string
|
||
choice int // 0 one machine, 1 high availability
|
||
|
||
admin, names textField
|
||
afocus int // admin, names, next
|
||
|
||
db [2]dbMachine
|
||
mfocus int // over both machines' fields, then the check button
|
||
|
||
conns []dbConn
|
||
listener *joinpkg.Listener
|
||
report preflight.Report
|
||
checking bool
|
||
|
||
in *serverInit
|
||
run initModel
|
||
finished bool
|
||
log *[]string
|
||
logMu *sync.Mutex
|
||
|
||
spin spinnerModel
|
||
formErr string
|
||
width int
|
||
height int
|
||
// scroll is how far the panel is scrolled, reset with each page.
|
||
scroll int
|
||
quit bool
|
||
// single is the choice of one machine: the screen closes and init's own
|
||
// form takes over.
|
||
single bool
|
||
}
|
||
|
||
func newSetupModel(host string, ips []string) setupModel {
|
||
admin := newTextField()
|
||
admin.Placeholder, admin.CharLimit, admin.Width = "you@example.com", 200, 40
|
||
names := newTextField()
|
||
names.Placeholder, names.CharLimit, names.Width = "llm.example.com, 203.0.113.10", 400, 40
|
||
return setupModel{
|
||
ref: &progRef{}, host: host, ips: ips, admin: admin, names: names,
|
||
db: [2]dbMachine{newDBMachine(1), newDBMachine(2)}, spin: newSpinner(), width: 100, height: 30,
|
||
log: &[]string{}, logMu: &sync.Mutex{},
|
||
}
|
||
}
|
||
|
||
func (m setupModel) Init() tea.Cmd { return m.spin.Tick }
|
||
|
||
func (m setupModel) thisAddr() string {
|
||
if len(m.ips) > 0 {
|
||
return m.ips[0]
|
||
}
|
||
return m.host
|
||
}
|
||
|
||
func (m setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
m.run.width = msg.Width - navWidth - 6
|
||
case spinTickMsg:
|
||
var cmd tea.Cmd
|
||
m.spin, cmd = m.spin.Update(msg)
|
||
m.run.spin = m.spin
|
||
return m, cmd
|
||
case checkDoneMsg:
|
||
m.checking, m.report = false, msg.rep
|
||
case joinedMsg:
|
||
m.db[msg.i].joined = msg.r
|
||
if m.page == pWait && m.allJoined() {
|
||
m.page = pCheck
|
||
cmd := m.startCheck()
|
||
return m, cmd
|
||
}
|
||
case initLineMsg:
|
||
m.run.addLine(string(msg))
|
||
case initDoneMsg:
|
||
m.run.done, m.run.err, m.finished = true, msg.err, true
|
||
case tea.KeyMsg:
|
||
if msg.Type == tea.KeyCtrlC {
|
||
m.quit = true
|
||
return m, tea.Quit
|
||
}
|
||
before := m.page
|
||
next, cmd := m.key(msg)
|
||
if nm := next.(setupModel); nm.page != before {
|
||
// A new page starts at its top.
|
||
nm.scroll = 0
|
||
return nm, cmd
|
||
}
|
||
return next, cmd
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m setupModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
// A dialog open takes the next key, whatever it is, to close it.
|
||
if m.formErr != "" {
|
||
m.formErr = ""
|
||
return m, nil
|
||
}
|
||
switch k.Type {
|
||
case tea.KeyPgDown:
|
||
m.scroll += max(1, m.height-8)
|
||
return m, nil
|
||
case tea.KeyPgUp:
|
||
m.scroll = max(0, m.scroll-max(1, m.height-8))
|
||
return m, nil
|
||
}
|
||
if k.Type == tea.KeyEsc && m.page > pWelcome && m.page < pRun && !m.checking {
|
||
m.page--
|
||
if m.page == pWait {
|
||
m.page = pMachines
|
||
}
|
||
m.formErr = ""
|
||
return m, nil
|
||
}
|
||
switch m.page {
|
||
case pWelcome:
|
||
switch k.String() {
|
||
case "up", "down", "tab", "k", "j":
|
||
m.choice = 1 - m.choice
|
||
case "enter":
|
||
if m.choice == 0 {
|
||
m.single = true
|
||
return m, tea.Quit
|
||
}
|
||
m.page = pAdmin
|
||
m.afocus = 0
|
||
m.admin.Focus()
|
||
}
|
||
case pAdmin:
|
||
return m.adminKey(k)
|
||
case pMachines:
|
||
return m.machinesKey(k)
|
||
case pCheck:
|
||
switch k.String() {
|
||
case "r":
|
||
if !m.checking {
|
||
cmd := m.startCheck()
|
||
return m, cmd
|
||
}
|
||
case "enter":
|
||
if !m.checking && m.report.Worst() < preflight.Fail {
|
||
m.page = pPlan
|
||
}
|
||
}
|
||
case pPlan:
|
||
if k.String() == "enter" {
|
||
m.page = pRun
|
||
cmd := m.startRun()
|
||
return m, cmd
|
||
}
|
||
case pRun:
|
||
if m.finished && (k.String() == "q" || k.String() == "enter") {
|
||
return m, tea.Quit
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
move := func(d int) {
|
||
m.afocus = (m.afocus + d + 3) % 3
|
||
m.admin.Blur()
|
||
m.names.Blur()
|
||
switch m.afocus {
|
||
case 0:
|
||
m.admin.Focus()
|
||
case 1:
|
||
m.names.Focus()
|
||
}
|
||
}
|
||
switch k.Type {
|
||
case tea.KeyTab, tea.KeyDown:
|
||
move(1)
|
||
return m, nil
|
||
case tea.KeyShiftTab, tea.KeyUp:
|
||
move(-1)
|
||
return m, nil
|
||
case tea.KeyEnter:
|
||
if m.afocus < 2 {
|
||
move(1)
|
||
return m, nil
|
||
}
|
||
if _, err := mail.ParseAddress(strings.TrimSpace(m.admin.Value())); err != nil {
|
||
m.formErr = "the administrator's email is needed, and that is not one"
|
||
m.afocus = 0
|
||
move(0)
|
||
return m, nil
|
||
}
|
||
m.formErr = ""
|
||
m.page, m.mfocus = pMachines, 0
|
||
m.db[0].addr.Focus()
|
||
return m, nil
|
||
}
|
||
var cmd tea.Cmd
|
||
switch m.afocus {
|
||
case 0:
|
||
m.admin, cmd = m.admin.Update(k)
|
||
case 1:
|
||
m.names, cmd = m.names.Update(k)
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
func (m *setupModel) focusMachine() {
|
||
for i := range m.db {
|
||
for f := 0; f < mfCount; f++ {
|
||
if t := m.db[i].field(f); t != nil {
|
||
t.Blur()
|
||
}
|
||
}
|
||
}
|
||
if m.mfocus < 2*mfCount {
|
||
if t := m.db[m.mfocus/mfCount].field(m.mfocus % mfCount); t != nil {
|
||
t.Focus()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (m setupModel) machinesKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
n := 2*mfCount + 1
|
||
switch k.Type {
|
||
case tea.KeyTab, tea.KeyDown:
|
||
m.step(1, n)
|
||
return m, nil
|
||
case tea.KeyShiftTab, tea.KeyUp:
|
||
m.step(-1, n)
|
||
return m, nil
|
||
case tea.KeyEnter:
|
||
if m.mfocus < 2*mfCount {
|
||
m.step(1, n)
|
||
return m, nil
|
||
}
|
||
if err := m.validateMachines(); err != "" {
|
||
m.formErr = err
|
||
return m, nil
|
||
}
|
||
m.formErr = ""
|
||
if cmd, waiting := m.startJoins(); waiting {
|
||
m.page = pWait
|
||
return m, cmd
|
||
}
|
||
m.page = pCheck
|
||
cmd := m.startCheck()
|
||
return m, cmd
|
||
}
|
||
if m.mfocus == 2*mfCount {
|
||
return m, nil
|
||
}
|
||
d := &m.db[m.mfocus/mfCount]
|
||
if m.mfocus%mfCount == mfAuth {
|
||
switch k.String() {
|
||
case "left", "h":
|
||
d.auth = (d.auth + authCount - 1) % authCount
|
||
case "right", "l", " ":
|
||
d.auth = (d.auth + 1) % authCount
|
||
}
|
||
if d.auth != authJoin && d.joined != nil {
|
||
_ = d.joined.Close()
|
||
d.joined, d.code, d.arrive = nil, "", nil
|
||
}
|
||
d.secret.SetValue("")
|
||
d.secret.Mask = d.auth == authPassword
|
||
d.secret.Placeholder = map[int]string{authKey: "/root/.ssh/id_ed25519", authPassword: "the user's ssh password"}[d.auth]
|
||
return m, nil
|
||
}
|
||
var cmd tea.Cmd
|
||
t := d.field(m.mfocus % mfCount)
|
||
*t, cmd = t.Update(k)
|
||
return m, cmd
|
||
}
|
||
|
||
// step moves the focus over the fields that are shown.
|
||
func (m *setupModel) step(d, n int) {
|
||
for range n {
|
||
m.mfocus = (m.mfocus + d + n) % n
|
||
if m.mfocus == 2*mfCount || m.db[m.mfocus/mfCount].visible(m.mfocus%mfCount) {
|
||
break
|
||
}
|
||
}
|
||
m.focusMachine()
|
||
}
|
||
|
||
func (m setupModel) allJoined() bool {
|
||
for _, d := range m.db {
|
||
if d.auth == authJoin && d.joined == nil {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// startJoins listens for the machines that join with a code, makes their
|
||
// codes, and waits for them; it says whether there is anything to wait for.
|
||
func (m *setupModel) startJoins() (tea.Cmd, bool) {
|
||
var cmds []tea.Cmd
|
||
for i := range m.db {
|
||
d := &m.db[i]
|
||
if d.auth != authJoin || d.joined != nil {
|
||
continue
|
||
}
|
||
if m.listener == nil {
|
||
l, err := joinpkg.Listen(fmt.Sprintf(":%d", joinpkg.DefaultPort),
|
||
fmt.Sprintf("%s:%d", m.thisAddr(), joinpkg.DefaultPort), time.Hour)
|
||
if err != nil {
|
||
m.formErr = err.Error()
|
||
return nil, false
|
||
}
|
||
m.listener = l
|
||
}
|
||
if d.arrive == nil {
|
||
c, ch := m.listener.Code("database")
|
||
d.code, d.arrive = c.String(), ch
|
||
}
|
||
ch, i := d.arrive, i
|
||
cmds = append(cmds, func() tea.Msg { return joinedMsg{i, <-ch} })
|
||
}
|
||
return tea.Batch(cmds...), len(cmds) > 0
|
||
}
|
||
|
||
func (m setupModel) validateMachines() string {
|
||
seen := map[string]bool{m.thisAddr(): true, m.host: true}
|
||
for i, d := range m.db {
|
||
if d.auth == authJoin {
|
||
continue
|
||
}
|
||
addr := strings.TrimSpace(d.addr.Value())
|
||
switch {
|
||
case addr == "":
|
||
return fmt.Sprintf("database machine %d has no address", i+1)
|
||
case seen[addr]:
|
||
return fmt.Sprintf("%s is given twice, or is this machine: the three must be different machines", addr)
|
||
case strings.TrimSpace(d.user.Value()) == "":
|
||
return fmt.Sprintf("database machine %d has no user to log in as", i+1)
|
||
case d.auth != authAgent && d.secret.Value() == "":
|
||
return fmt.Sprintf("database machine %d needs its %s", i+1, authNames[d.auth])
|
||
}
|
||
seen[addr] = true
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// startCheck connects to the database machines and runs the checks on all
|
||
// three, this one included.
|
||
func (m *setupModel) startCheck() tea.Cmd {
|
||
for _, c := range m.conns {
|
||
if c.ssh != nil {
|
||
_ = c.ssh.Close()
|
||
}
|
||
}
|
||
m.conns = nil
|
||
machines := []preflight.Machine{{
|
||
Name: m.host + " (this machine)", Addr: m.thisAddr(), Local: true, Remote: pgha.Local{},
|
||
Roles: []preflight.Role{preflight.RoleControl, preflight.RoleWitness},
|
||
}}
|
||
for _, d := range m.db {
|
||
if d.auth == authJoin {
|
||
m.conns = append(m.conns, dbConn{remote: d.joined, addr: d.joined.Addr, joined: d.joined})
|
||
machines = append(machines, preflight.Machine{Name: d.joined.Hostname + " (" + d.joined.Addr + ", joined)",
|
||
Addr: d.joined.Addr, Roles: []preflight.Role{preflight.RoleDatabase}, Remote: d.joined})
|
||
continue
|
||
}
|
||
g := &onboard.GoSSH{
|
||
Addr: strings.TrimSpace(d.addr.Value()), User: strings.TrimSpace(d.user.Value()),
|
||
UseAgent: d.auth == authAgent, SudoPassword: d.sudo.Value(),
|
||
}
|
||
switch d.auth {
|
||
case authKey:
|
||
g.KeyFile = strings.TrimSpace(d.secret.Value())
|
||
case authPassword:
|
||
g.Password = d.secret.Value()
|
||
}
|
||
m.conns = append(m.conns, dbConn{remote: g, addr: g.Addr, ssh: g})
|
||
machines = append(machines, preflight.Machine{Name: g.Addr, Addr: g.Addr,
|
||
Roles: []preflight.Role{preflight.RoleDatabase}, Remote: g})
|
||
}
|
||
m.checking = true
|
||
m.report = preflight.Report{}
|
||
conns := m.conns
|
||
return func() tea.Msg {
|
||
rep := preflight.Run(context.Background(), machines)
|
||
// A sudo that asks for a password is fine with one given: the
|
||
// install grants itself sudo with it.
|
||
for i := range rep.Findings {
|
||
f := &rep.Findings[i]
|
||
if f.Check == "sudo" && f.Level == preflight.Warn {
|
||
for _, c := range conns {
|
||
g := c.ssh
|
||
switch {
|
||
case g == nil || g.Addr != f.Machine:
|
||
case g.SudoPassword == "":
|
||
f.Level = preflight.Fail
|
||
f.Fix = "give this user's sudo password on the previous screen"
|
||
default:
|
||
f.Level = preflight.OK
|
||
f.Detail = "with the password given"
|
||
f.Fix = ""
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return checkDoneMsg{rep}
|
||
}
|
||
}
|
||
|
||
// targets are the database machines as user@address, for the command line.
|
||
func (m setupModel) targets() []string {
|
||
var out []string
|
||
for _, d := range m.db {
|
||
if d.joined != nil {
|
||
out = append(out, "root@"+d.joined.Addr)
|
||
continue
|
||
}
|
||
out = append(out, strings.TrimSpace(d.user.Value())+"@"+strings.TrimSpace(d.addr.Value()))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (m setupModel) namesList() []string {
|
||
var out []string
|
||
for _, h := range strings.Split(m.names.Value(), ",") {
|
||
if h = strings.TrimSpace(h); h != "" {
|
||
out = append(out, h)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// command is what this setup is on the command line, for doing it again
|
||
// from a script — with keys in place, since a password cannot be given there.
|
||
func (m setupModel) command() string {
|
||
parts := []string{"sudo --preserve-env=SSH_AUTH_SOCK llmgw server init",
|
||
"--admin " + strings.TrimSpace(m.admin.Value())}
|
||
for _, h := range m.namesList() {
|
||
parts = append(parts, "--host "+h)
|
||
}
|
||
for _, t := range m.targets() {
|
||
parts = append(parts, "--patroni "+t)
|
||
}
|
||
parts = append(parts, "--no-node", "--no-model")
|
||
return strings.Join(parts, " \\\n ")
|
||
}
|
||
|
||
// startRun installs: sudo granted where it asks for a password, the control
|
||
// plane here, the store on the other two, then sudo taken back.
|
||
func (m *setupModel) startRun() tea.Cmd {
|
||
in := newDefaultServerInit()
|
||
in.admin = strings.TrimSpace(m.admin.Value())
|
||
in.hosts = m.namesList()
|
||
in.patroni = m.targets()
|
||
// The control plane of a replicated setup is not where models run;
|
||
// nodes are added once it is up.
|
||
in.noNode, in.noModel = true, true
|
||
m.in = &in
|
||
m.run = newInitModel(&in, nil, false)
|
||
m.run.running, m.run.width, m.run.spin = true, m.width, m.spin
|
||
ref, conns, logp, mu, listener := m.ref, m.conns, m.log, m.logMu, m.listener
|
||
return func() tea.Msg {
|
||
send := func(l string) {
|
||
mu.Lock()
|
||
*logp = append(*logp, l)
|
||
mu.Unlock()
|
||
ref.p.Send(initLineMsg(l))
|
||
}
|
||
in.out = &lineWriter{send: send}
|
||
err := func() error {
|
||
ctx := context.Background()
|
||
for _, c := range conns {
|
||
if c.ssh != nil {
|
||
fmt.Fprintf(in.out, " .. sudo on %s\n", c.addr)
|
||
if err := c.ssh.PrepareSudo(ctx); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
out, err := c.remote.Run(ctx, "hostname", nil)
|
||
if err != nil {
|
||
return fmt.Errorf("%s: %v", c.addr, err)
|
||
}
|
||
ip, err := ipv4Of(c.addr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
in.patroniNodes = append(in.patroniNodes, pgha.Node{
|
||
Member: pgha.Member{Name: strings.TrimSpace(string(out)), IP: ip}, Remote: c.remote})
|
||
}
|
||
return in.run(ctx)
|
||
}()
|
||
// Whatever happened: a sudo grant taken back, a joined machine's
|
||
// `llmgw join` told it is done, the port for joining closed.
|
||
for _, c := range conns {
|
||
if c.ssh != nil {
|
||
_ = c.ssh.Close()
|
||
}
|
||
if c.joined != nil {
|
||
_ = c.joined.Close()
|
||
}
|
||
}
|
||
if listener != nil {
|
||
_ = listener.Close()
|
||
}
|
||
return initDoneMsg{err}
|
||
}
|
||
}
|
||
|
||
// setupSteps are the sidebar's steps, by page.
|
||
var setupSteps = []struct {
|
||
label string
|
||
page setupPage
|
||
}{
|
||
{"Start", pWelcome}, {"Administrator", pAdmin}, {"Machines", pMachines},
|
||
{"Checks", pCheck}, {"Plan", pPlan}, {"Install", pRun},
|
||
}
|
||
|
||
func (m setupModel) View() string {
|
||
at := m.page
|
||
if at == pWait {
|
||
at = pMachines
|
||
}
|
||
var nav []navItem
|
||
for _, st := range setupSteps {
|
||
if m.choice == 0 && st.page > pWelcome {
|
||
break
|
||
}
|
||
state := "todo"
|
||
switch {
|
||
case st.page < at || (st.page == pRun && m.finished && m.run.err == nil):
|
||
state = "done"
|
||
case st.page == at:
|
||
state = "current"
|
||
}
|
||
nav = append(nav, navItem{Label: st.label, State: state})
|
||
}
|
||
title, body, keys := m.pageView()
|
||
f := frame{
|
||
Width: m.width, Height: m.height,
|
||
Title: "llmgw setup", Where: m.host + " · " + m.thisAddr(),
|
||
NavTitle: "Steps", Nav: nav,
|
||
PanelTitle: title, Body: body, Scroll: m.scroll, Keys: keys,
|
||
}
|
||
if m.formErr != "" {
|
||
f.DialogTitle, f.Dialog = "Not yet", m.formErr+"\n\n"+sDim.Render("any key to go back")
|
||
}
|
||
return f.render()
|
||
}
|
||
|
||
// pageView is the panel for the page: its title, what is in it, and its keys.
|
||
func (m setupModel) pageView() (string, string, string) {
|
||
switch m.page {
|
||
case pWelcome:
|
||
return "Start", m.welcomeView(), "↑↓ choose · Enter go on · Ctrl+C leave"
|
||
case pAdmin:
|
||
return "Administrator", m.adminView(), "Tab/↑↓ move · Enter next · Esc back · Ctrl+C leave"
|
||
case pMachines:
|
||
return "Database machines", m.machinesView(), "Tab/↑↓ move · ←→ how to log in · Enter next · Esc back · PgUp/PgDn scroll"
|
||
case pWait:
|
||
return "Waiting for machines to join", m.waitView(), "Esc back · PgUp/PgDn scroll · Ctrl+C leave"
|
||
case pCheck:
|
||
keys := "R check again · Esc back · PgUp/PgDn scroll"
|
||
if !m.checking && m.report.Worst() < preflight.Fail {
|
||
keys = "Enter see the plan · " + keys
|
||
}
|
||
return "Checks", m.checkView(), keys
|
||
case pPlan:
|
||
return "Plan", m.planView(), "Enter install · Esc back · PgUp/PgDn scroll · Ctrl+C leave"
|
||
}
|
||
v := m.run.View()
|
||
if i := strings.Index(v, "\n\n"); i >= 0 {
|
||
v = v[i+2:] // init's own title; the frame has one
|
||
}
|
||
keys := "Ctrl+C stop"
|
||
if m.finished {
|
||
keys = "Enter or Q leave · PgUp/PgDn scroll"
|
||
if m.run.err == nil {
|
||
v += "\n" + sDim.Render("The same, from a script, with ssh keys in place:") + "\n" + m.command() + "\n"
|
||
}
|
||
}
|
||
return "Install", v, keys
|
||
}
|
||
|
||
func (m setupModel) welcomeView() string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "\n Nothing of llmgw is installed on %s.\n\n", lipgloss.NewStyle().Bold(true).Render(m.host))
|
||
opt := func(i int, title, detail string) {
|
||
mark := "( )"
|
||
if m.choice == i {
|
||
mark = "(" + sOK.Render("•") + ")"
|
||
title = fSelected.Render(title)
|
||
}
|
||
fmt.Fprintf(&b, " %s %s\n %s\n\n", mark, title, sDim.Render(detail))
|
||
}
|
||
opt(0, "One machine", "This machine runs everything: control plane, model mirror,\n and models if it has room.")
|
||
opt(1, "High availability — three machines",
|
||
"This one is the control plane; two more keep the store, replicated,\n so losing any one machine loses nothing.")
|
||
return b.String()
|
||
}
|
||
|
||
func (m setupModel) adminView() string {
|
||
var b strings.Builder
|
||
label := func(i int, s string) string {
|
||
if m.afocus == i {
|
||
return fSelected.Render("› " + s)
|
||
}
|
||
return " " + s
|
||
}
|
||
b.WriteString("\n")
|
||
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(0, "Administrator's email"), m.admin.View(),
|
||
sDim.Render("A password is generated and shown once, at the end."))
|
||
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(1, "Other names nodes will reach this machine by"), m.names.View(),
|
||
sDim.Render("Known: "+strings.Join(append([]string{m.host}, m.ips...), ", ")+". Add a DNS name or a floating address."))
|
||
b.WriteString(button(m.afocus == 2, "Next") + "\n")
|
||
return b.String()
|
||
}
|
||
|
||
// button is a [ Label ] that shows when it has the focus.
|
||
func button(focused bool, label string) string {
|
||
if focused {
|
||
return " " + fBar.Render(" "+label+" ")
|
||
}
|
||
return " " + lipgloss.NewStyle().Bold(true).Render("[ "+label+" ]")
|
||
}
|
||
|
||
func (m setupModel) machinesView() string {
|
||
var b strings.Builder
|
||
b.WriteString("\n The two machines that keep the store: Ubuntu 22.04/24.04 or Debian 12.\n" +
|
||
" PostgreSQL, Patroni and etcd are installed on them.\n\n")
|
||
for i, d := range m.db {
|
||
base := i * mfCount
|
||
// Padded before it is coloured: the colour's escape codes count
|
||
// toward a width and threw the focused row out of line.
|
||
label := func(f int, s string) string {
|
||
s = fmt.Sprintf("%-15s", s)
|
||
if m.mfocus == base+f {
|
||
return fSelected.Render("› " + s)
|
||
}
|
||
return " " + s
|
||
}
|
||
fmt.Fprintf(&b, " %s\n", lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("Database machine %d", i+1)))
|
||
if d.visible(mfAddr) {
|
||
fmt.Fprintf(&b, " %s %s\n", label(mfAddr, "Address"), d.addr.View())
|
||
fmt.Fprintf(&b, " %s %s\n", label(mfUser, "User"), d.user.View())
|
||
}
|
||
var auth []string
|
||
for a := 0; a < authCount; a++ {
|
||
if a == d.auth {
|
||
auth = append(auth, "("+sOK.Render("•")+") "+authNames[a])
|
||
} else {
|
||
auth = append(auth, "( ) "+sDim.Render(authNames[a]))
|
||
}
|
||
}
|
||
fmt.Fprintf(&b, " %s %s\n", label(mfAuth, "Log in with"), strings.Join(auth, " "))
|
||
if d.visible(mfSecret) {
|
||
fmt.Fprintf(&b, " %s %s\n", label(mfSecret, strings.ToUpper(authNames[d.auth][:1])+authNames[d.auth][1:]), d.secret.View())
|
||
}
|
||
if d.visible(mfSudo) {
|
||
fmt.Fprintf(&b, " %s %s\n", label(mfSudo, "Sudo password"), d.sudo.View())
|
||
}
|
||
if d.auth == authJoin {
|
||
b.WriteString(" " + sDim.Render("Nothing logs into it: it runs llmgw join with a code shown next.") + "\n")
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
b.WriteString(button(m.mfocus == 2*mfCount, "Check the three machines") + "\n\n")
|
||
b.WriteString(" " + sDim.Render("Passwords are kept in memory only, for this setup.") + "\n")
|
||
return b.String()
|
||
}
|
||
|
||
func (m setupModel) waitView() string {
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "\n On each machine below, as root. It dials this machine on port %d;\n nothing dials in. Each code is for one machine and good for an hour.\n\n", joinpkg.DefaultPort)
|
||
for i, d := range m.db {
|
||
if d.auth != authJoin {
|
||
continue
|
||
}
|
||
title := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("Database machine %d", i+1))
|
||
if d.joined != nil {
|
||
fmt.Fprintf(&b, " %s %s joined as %s (%s)\n\n", title, sOK.Render("✓"), d.joined.Hostname, d.joined.Addr)
|
||
continue
|
||
}
|
||
fmt.Fprintf(&b, " %s %s waiting\n", title, m.spin.View())
|
||
b.WriteString(" " + sDim.Render("without llmgw yet:") + "\n")
|
||
for _, l := range joinInstall() {
|
||
b.WriteString(" " + strings.ReplaceAll(l, "\n", "\n ") + "\n")
|
||
}
|
||
b.WriteString(" " + sDim.Render("then:") + "\n sudo llmgw join " + d.code + "\n\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (m setupModel) checkView() string {
|
||
var b strings.Builder
|
||
b.WriteString("\n")
|
||
if m.checking {
|
||
b.WriteString(" " + m.spin.View() + " checking the three machines…\n")
|
||
return b.String()
|
||
}
|
||
switch m.report.Worst() {
|
||
case preflight.Fail:
|
||
b.WriteString(" " + sBad.Render("✗ Fix what is marked ✗, then check again (R).") + "\n")
|
||
case preflight.Warn:
|
||
b.WriteString(" " + sWarn.Render("! It can go ahead; what is marked ! is worth a look.") + "\n")
|
||
default:
|
||
b.WriteString(" " + sOK.Render("✓ All three are ready.") + "\n")
|
||
}
|
||
machine := ""
|
||
for _, f := range m.report.Findings {
|
||
if f.Machine != machine {
|
||
machine = f.Machine
|
||
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render(machine) + "\n")
|
||
}
|
||
mark := sOK.Render("✓")
|
||
switch f.Level {
|
||
case preflight.Warn:
|
||
mark = sWarn.Render("!")
|
||
case preflight.Fail:
|
||
mark = sBad.Render("✗")
|
||
}
|
||
fmt.Fprintf(&b, " %s %-13s %s\n", mark, f.Check, f.Detail)
|
||
if f.Level != preflight.OK && f.Fix != "" {
|
||
b.WriteString(" " + sDim.Render(f.Fix) + "\n")
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (m setupModel) planView() string {
|
||
var b strings.Builder
|
||
b.WriteString("\n What will be installed:\n\n")
|
||
fmt.Fprintf(&b, " %s %s\n %s\n", lipgloss.NewStyle().Bold(true).Render(m.host), sDim.Render(m.thisAddr()),
|
||
"control plane and gateway, model mirror, etcd (the third vote)")
|
||
fmt.Fprintf(&b, " administrator %s\n", strings.TrimSpace(m.admin.Value()))
|
||
granted := false
|
||
for _, d := range m.db {
|
||
addr := strings.TrimSpace(d.addr.Value())
|
||
if d.joined != nil {
|
||
addr = d.joined.Hostname + " " + sDim.Render(d.joined.Addr+", joined")
|
||
}
|
||
fmt.Fprintf(&b, " %s\n PostgreSQL under Patroni, etcd\n", lipgloss.NewStyle().Bold(true).Render(addr))
|
||
granted = granted || d.sudo.Value() != ""
|
||
}
|
||
b.WriteString("\n The store is replicated from the first to the second; if the primary is lost\n" +
|
||
" the standby takes over in about thirty seconds, and the control plane follows.\n")
|
||
if granted {
|
||
b.WriteString(" " + sDim.Render("Where a sudo password was given, the user may sudo without it until the\n install finishes, and not after.") + "\n")
|
||
}
|
||
b.WriteString("\n " + sDim.Render("The same from a script, with ssh keys in place:") + "\n")
|
||
for _, l := range strings.Split(m.command(), "\n") {
|
||
b.WriteString(" " + l + "\n")
|
||
}
|
||
for _, d := range m.db {
|
||
if d.joined != nil {
|
||
b.WriteString(" " + sDim.Render("(a machine that joined with a code needs ssh for that)") + "\n")
|
||
break
|
||
}
|
||
}
|
||
b.WriteString("\n" + button(true, "Enter to install") + "\n")
|
||
return b.String()
|
||
}
|
||
|
||
// runSetupTUI is the setup screens, until they finish or hand over to init's
|
||
// own form for one machine.
|
||
func runSetupTUI(ctx context.Context, out io.Writer) error {
|
||
host, _ := os.Hostname()
|
||
var ips []string
|
||
for _, ip := range localAddrs() {
|
||
if ip.To4() != nil {
|
||
ips = append(ips, ip.String())
|
||
}
|
||
}
|
||
m := newSetupModel(host, ips)
|
||
p := tea.NewProgram(m, tea.WithOutput(out), tea.WithAltScreen())
|
||
m.ref.p = p
|
||
final, err := p.Run()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fm := final.(setupModel)
|
||
if fm.single {
|
||
in := newDefaultServerInit()
|
||
in.out = out
|
||
return in.runInitTUI(ctx, out)
|
||
}
|
||
if fm.page < pRun {
|
||
return nil
|
||
}
|
||
fmt.Fprintln(out)
|
||
if fm.run.err != nil {
|
||
fm.logMu.Lock()
|
||
fmt.Fprintln(out, strings.Join(*fm.log, "\n"))
|
||
fm.logMu.Unlock()
|
||
return fm.run.err
|
||
}
|
||
if !fm.finished {
|
||
return fmt.Errorf("left while installing; running llmgw again finishes what was started")
|
||
}
|
||
if fm.run.password != "" {
|
||
fmt.Fprintf(out, "Administrator %s, password %s (shown once)\n\n", fm.in.admin, fm.run.password)
|
||
}
|
||
fmt.Fprintln(out, "Next: add nodes from the console — run llmgw again — or with")
|
||
fmt.Fprintln(out, " sudo llmgw node add <user>@<address>")
|
||
return nil
|
||
}
|