Files
llmgw/internal/cli/tui_setup.go
T
Ilker ManapandClaude Opus 5.5 f5c79052b1 Let a machine join a setup by code, dialling out, with nothing logging in
The setup screens can reach a database machine by a join code instead of
ssh: they listen on 7444 and show a short code; llmgw join dials out,
checks the setup by fingerprint, admits itself with the code's secret, and
runs what the setup sends until it is done. A joined machine is one more
onboard.Remote, so checks and the Patroni install run over it unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 21:49:15 +02:00

906 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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,
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 = msg.Width
m.run.width = msg.Width
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
}
return m.key(msg)
}
return m, nil
}
func (m setupModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
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}
}
}
func (m setupModel) View() string {
var b strings.Builder
b.WriteString(sTitle.Render("llmgw · setup") + " " + sDim.Render(m.crumbs()) + "\n\n")
switch m.page {
case pWelcome:
b.WriteString(m.welcomeView())
case pAdmin:
b.WriteString(m.adminView())
case pMachines:
b.WriteString(m.machinesView())
case pWait:
b.WriteString(m.waitView())
case pCheck:
b.WriteString(m.checkView())
case pPlan:
b.WriteString(m.planView())
case pRun:
v := m.run.View()
if i := strings.Index(v, "\n\n"); i >= 0 {
v = v[i+2:] // its own title; this screen has one
}
b.WriteString(v)
if m.finished {
if m.run.err == nil {
b.WriteString("\n" + sDim.Render("The same, from a script, with ssh keys in place:") + "\n" + m.command() + "\n")
}
b.WriteString("\n" + sDim.Render("enter or q to leave") + "\n")
}
}
if m.formErr != "" {
b.WriteString("\n" + sBad.Render(m.formErr) + "\n")
}
return b.String()
}
func (m setupModel) crumbs() string {
steps := []string{"start", "administrator", "machines", "checks", "plan", "install"}
if m.choice == 0 {
return steps[0]
}
at := int(m.page)
switch {
case m.page == pWait:
at = int(pMachines)
case m.page > pWait:
at--
}
var out []string
for i, s := range steps {
if i == at {
s = lipgloss.NewStyle().Bold(true).Render(s)
}
out = append(out, s)
}
return strings.Join(out, " › ")
}
func (m setupModel) welcomeView() string {
var b strings.Builder
fmt.Fprintf(&b, "Nothing of llmgw is installed on %s (%s).\n\n", m.host, strings.Join(m.ips, ", "))
opt := func(i int, title, detail string) {
mark := "( )"
if m.choice == i {
mark = "(" + sOK.Render("•") + ")"
title = sTitle.Render(title)
}
fmt.Fprintf(&b, " %s %s\n %s\n\n", mark, title, sDim.Render(detail))
}
opt(0, "One machine", "this machine runs everything: the control plane, a model mirror, and models if it has room.")
opt(1, "High availability — three machines",
"this one is the control plane; two more keep the store, replicated, so losing any one loses nothing.")
b.WriteString(sDim.Render("↑↓ to choose · enter to go on · ctrl+c to leave") + "\n")
return b.String()
}
func (m setupModel) adminView() string {
var b strings.Builder
label := func(i int, s string) string {
if m.afocus == i {
return sTitle.Render("› " + s)
}
return " " + s
}
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("Already known: "+strings.Join(append([]string{m.host}, m.ips...), ", ")+". Add a DNS name or a floating address."))
b.WriteString(label(2, lipgloss.NewStyle().Bold(true).Render("Next")) + "\n\n")
b.WriteString(sDim.Render("tab or ↑↓ to move · enter on Next · esc back") + "\n")
return b.String()
}
func (m setupModel) machinesView() string {
var b strings.Builder
b.WriteString("The two machines that keep the store. They need ssh, a user that can sudo, and\n" +
"Ubuntu 22.04/24.04 or Debian 12; 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("%-16s", s)
if m.mfocus == base+f {
return sTitle.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, authNames[d.auth]), 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, and dials out to this machine.") + "\n")
}
b.WriteString("\n")
}
btn := lipgloss.NewStyle().Bold(true).Render("Check the three machines")
if m.mfocus == 2*mfCount {
btn = sTitle.Render("› " + btn)
} else {
btn = " " + btn
}
b.WriteString(btn + "\n\n")
b.WriteString(sDim.Render("tab or ↑↓ to move · ←→ to choose how to log in · passwords are kept in memory only · esc back") + "\n")
return b.String()
}
func (m setupModel) waitView() string {
var b strings.Builder
fmt.Fprintf(&b, "On each machine below, as root. It dials this machine on port %d; nothing dials in.\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")
}
b.WriteString(sDim.Render("Each code is for one machine and good for an hour. esc back · ctrl+c to leave") + "\n")
return b.String()
}
func (m setupModel) checkView() string {
var b strings.Builder
if m.checking {
b.WriteString(" " + m.spin.View() + " checking the three machines…\n")
return b.String()
}
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("✗")
}
line := fmt.Sprintf(" %s %-13s %s", mark, f.Check, truncate(f.Detail, max(20, m.width-22)))
if f.Level != preflight.OK && f.Fix != "" {
line += "\n " + sDim.Render(truncate(f.Fix, max(20, m.width-20)))
}
b.WriteString(line + "\n")
}
b.WriteString("\n")
switch m.report.Worst() {
case preflight.Fail:
b.WriteString(sBad.Render("Fix what is marked ✗, then check again.") + "\n" +
sDim.Render("r to check again · esc to change the machines") + "\n")
case preflight.Warn:
b.WriteString(sWarn.Render("It can go ahead; what is marked ! is worth a look.") + "\n" +
sDim.Render("enter to see the plan · r to check again · esc back") + "\n")
default:
b.WriteString(sOK.Render("All three are ready.") + "\n" + sDim.Render("enter to see the plan · esc back") + "\n")
}
return b.String()
}
func (m setupModel) planView() string {
var b strings.Builder
b.WriteString("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), administrator "+strings.TrimSpace(m.admin.Value()))
granted := false
for _, d := range m.db {
fmt.Fprintf(&b, " %s\n %s\n", lipgloss.NewStyle().Bold(true).Render(strings.TrimSpace(d.addr.Value())),
"PostgreSQL under Patroni, etcd")
granted = granted || d.sudo.Value() != ""
}
b.WriteString("\n The store is replicated from the first to the second; if the primary is lost the\n" +
" standby takes over in about thirty seconds, and the control plane follows it.\n")
if granted {
b.WriteString(" " + sDim.Render("Where a sudo password was given, the user may sudo without it until the install\n finishes, and not after.") + "\n")
}
b.WriteString("\n" + sDim.Render("The same from a script, with ssh keys in place:") + "\n" + m.command() + "\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: the code is for these screens)") + "\n")
break
}
}
b.WriteString("\n")
b.WriteString(lipgloss.NewStyle().Bold(true).Render("enter to install") + sDim.Render(" · esc back · ctrl+c to leave") + "\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))
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
}