mirror init sets a machine up as the model mirror — the user, /srv/models, and a unit serving files on :8081 and the management API on :8082 — and server init --mirror points the control plane at it instead of running one. Before, that took a unit written by hand and an edit to server.yaml. The management API can fetch and delete models and answered anyone who could reach it. mirror serve --api-allow limits it to the addresses given and the machine itself; mirror init passes the control plane's. The unit text is shared with the one server init writes for a mirror beside it. With --no-node the starter model is still fetched and catalogued, unpinned, so it starts on the first node added, and the init form shows the mirror in use instead of offering to run one. Tried on three empty VMs: the mirror answered the control plane and refused the node, and the starter model came up on the node when it was added. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
404 lines
11 KiB
Go
404 lines
11 KiB
Go
package cli
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"net/mail"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/charmbracelet/bubbles/spinner"
|
||
"github.com/charmbracelet/bubbles/textinput"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
// The full-screen way through 'llmgw server init': a short form for what
|
||
// init would otherwise take as flags, then init's own lines as a list that
|
||
// fills in, with the administrator's password kept in view — and printed
|
||
// again once the screen closes, so it is in the terminal's history.
|
||
|
||
// initLineMsg is one line init wrote.
|
||
type initLineMsg string
|
||
|
||
// initDoneMsg is init finishing.
|
||
type initDoneMsg struct{ err error }
|
||
|
||
// lineWriter turns init's output into one message per line.
|
||
type lineWriter struct {
|
||
mu sync.Mutex
|
||
buf []byte
|
||
send func(string)
|
||
}
|
||
|
||
func (w *lineWriter) Write(p []byte) (int, error) {
|
||
w.mu.Lock()
|
||
defer w.mu.Unlock()
|
||
w.buf = append(w.buf, p...)
|
||
for {
|
||
i := strings.IndexByte(string(w.buf), '\n')
|
||
if i < 0 {
|
||
return len(p), nil
|
||
}
|
||
w.send(string(w.buf[:i]))
|
||
w.buf = w.buf[i+1:]
|
||
}
|
||
}
|
||
|
||
// Form fields, in order.
|
||
const (
|
||
fAdmin = iota
|
||
fHosts
|
||
fPostgres
|
||
fMirror
|
||
fNode
|
||
fModel
|
||
fStart
|
||
fCount
|
||
)
|
||
|
||
type initModel struct {
|
||
in *serverInit
|
||
found []string // this machine's addresses
|
||
|
||
// The form, until it is submitted.
|
||
form bool
|
||
focus int
|
||
admin textinput.Model
|
||
hosts textinput.Model
|
||
toggles [fCount]bool
|
||
formError string
|
||
|
||
// Then the run.
|
||
running bool
|
||
lines []string // init's lines, as it wrote them
|
||
password string
|
||
next []string // the closing "Next:" text
|
||
spin spinner.Model
|
||
width int
|
||
done bool
|
||
err error
|
||
quit bool
|
||
start func()
|
||
}
|
||
|
||
func newInitModel(in *serverInit, found []string, askForm bool) initModel {
|
||
admin := textinput.New()
|
||
admin.Placeholder = "you@example.com"
|
||
admin.SetValue(in.admin)
|
||
admin.CharLimit = 200
|
||
admin.Width = 40
|
||
hosts := textinput.New()
|
||
hosts.Placeholder = "llm.example.com, 203.0.113.10"
|
||
hosts.SetValue(strings.Join(in.hosts, ", "))
|
||
hosts.CharLimit = 400
|
||
hosts.Width = 40
|
||
sp := spinner.New()
|
||
sp.Spinner = spinner.MiniDot
|
||
sp.Style = lipgloss.NewStyle().Foreground(tuiAccent)
|
||
m := initModel{in: in, found: found, form: askForm, admin: admin, hosts: hosts, spin: sp, width: 100}
|
||
m.toggles[fPostgres] = in.postgres
|
||
m.toggles[fMirror] = !in.noMirror
|
||
m.toggles[fNode] = !in.noNode
|
||
m.toggles[fModel] = !in.noModel
|
||
if askForm {
|
||
m.admin.Focus()
|
||
}
|
||
return m
|
||
}
|
||
|
||
func (m initModel) Init() tea.Cmd {
|
||
if m.form {
|
||
return textinput.Blink
|
||
}
|
||
return tea.Batch(m.spin.Tick, func() tea.Msg { return startInitMsg{} })
|
||
}
|
||
|
||
type startInitMsg struct{}
|
||
|
||
func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
m.width = msg.Width
|
||
case spinner.TickMsg:
|
||
var cmd tea.Cmd
|
||
m.spin, cmd = m.spin.Update(msg)
|
||
return m, cmd
|
||
case startInitMsg:
|
||
m.running = true
|
||
if m.start != nil {
|
||
m.start()
|
||
}
|
||
case initLineMsg:
|
||
m.addLine(string(msg))
|
||
case initDoneMsg:
|
||
m.done, m.err = true, msg.err
|
||
return m, tea.Quit
|
||
case tea.KeyMsg:
|
||
if msg.Type == tea.KeyCtrlC {
|
||
m.quit = true
|
||
return m, tea.Quit
|
||
}
|
||
if m.form {
|
||
return m.formKey(msg)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// addLine files one of init's lines: the password aside, the closing text
|
||
// aside, a progress line replacing the one before it.
|
||
func (m *initModel) addLine(l string) {
|
||
t := strings.TrimSpace(l)
|
||
switch {
|
||
case t == "":
|
||
return
|
||
case strings.HasPrefix(t, "password: "):
|
||
m.password = strings.TrimPrefix(t, "password: ")
|
||
return
|
||
case strings.HasPrefix(t, "shown once"):
|
||
return
|
||
case !strings.HasPrefix(l, " ") || len(m.next) > 0:
|
||
// Unindented lines are the closing text, and everything after its
|
||
// first line belongs to it.
|
||
m.next = append(m.next, l)
|
||
return
|
||
}
|
||
if strings.HasPrefix(t, "..") && len(m.lines) > 0 && strings.HasPrefix(strings.TrimSpace(m.lines[len(m.lines)-1]), "..") {
|
||
m.lines[len(m.lines)-1] = l
|
||
return
|
||
}
|
||
m.lines = append(m.lines, l)
|
||
}
|
||
|
||
func (m initModel) formKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch k.Type {
|
||
case tea.KeyTab, tea.KeyDown:
|
||
m.moveFocus(1)
|
||
return m, nil
|
||
case tea.KeyShiftTab, tea.KeyUp:
|
||
m.moveFocus(-1)
|
||
return m, nil
|
||
case tea.KeyEnter:
|
||
if m.focus != fStart {
|
||
m.moveFocus(1)
|
||
return m, nil
|
||
}
|
||
admin := strings.TrimSpace(m.admin.Value())
|
||
if admin != "" {
|
||
if _, err := mail.ParseAddress(admin); err != nil {
|
||
m.formError = "the administrator's email is not an address"
|
||
m.focus = fAdmin
|
||
m.admin.Focus()
|
||
return m, nil
|
||
}
|
||
}
|
||
m.in.admin = admin
|
||
m.in.hosts = nil
|
||
for _, h := range strings.Split(m.hosts.Value(), ",") {
|
||
if h = strings.TrimSpace(h); h != "" {
|
||
m.in.hosts = append(m.in.hosts, h)
|
||
}
|
||
}
|
||
m.in.postgres = m.toggles[fPostgres]
|
||
m.in.noMirror = !m.toggles[fMirror]
|
||
m.in.noNode = !m.toggles[fNode]
|
||
m.in.noModel = !m.toggles[fModel]
|
||
m.form = false
|
||
return m, tea.Batch(m.spin.Tick, func() tea.Msg { return startInitMsg{} })
|
||
}
|
||
switch m.focus {
|
||
case fAdmin:
|
||
var cmd tea.Cmd
|
||
m.admin, cmd = m.admin.Update(k)
|
||
return m, cmd
|
||
case fHosts:
|
||
var cmd tea.Cmd
|
||
m.hosts, cmd = m.hosts.Update(k)
|
||
return m, cmd
|
||
case fPostgres, fMirror, fNode, fModel:
|
||
if k.String() == " " || k.String() == "x" {
|
||
m.toggles[m.focus] = !m.toggles[m.focus]
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m *initModel) moveFocus(d int) {
|
||
m.focus = (m.focus + d + fCount) % fCount
|
||
// With --mirror there is no mirror to run here, so nothing to tick.
|
||
if m.focus == fMirror && m.in.mirrorURL != "" {
|
||
m.focus = (m.focus + d + fCount) % fCount
|
||
}
|
||
m.admin.Blur()
|
||
m.hosts.Blur()
|
||
switch m.focus {
|
||
case fAdmin:
|
||
m.admin.Focus()
|
||
case fHosts:
|
||
m.hosts.Focus()
|
||
}
|
||
}
|
||
|
||
func (m initModel) View() string {
|
||
var b strings.Builder
|
||
b.WriteString(sTitle.Render("llmgw · setting up this machine as a control plane") + "\n\n")
|
||
if m.form {
|
||
return b.String() + m.formView()
|
||
}
|
||
for _, l := range m.visibleLines() {
|
||
b.WriteString(m.lineView(l) + "\n")
|
||
}
|
||
if m.password != "" {
|
||
box := sBox.BorderForeground(tuiWarn).Render(
|
||
fmt.Sprintf("Administrator %s\npassword %s\n%s", m.in.admin,
|
||
lipgloss.NewStyle().Bold(true).Render(m.password),
|
||
sDim.Render("Shown once. Copy it now; change it on the account page after signing in.")))
|
||
b.WriteString("\n" + box + "\n")
|
||
}
|
||
switch {
|
||
case m.done && m.err != nil:
|
||
b.WriteString("\n" + sBad.Render("✗ ") + wrap(m.err.Error(), m.width-4, " ") + "\n" +
|
||
sDim.Render(" It is safe to run init again once that is fixed: it finishes what is missing.") + "\n")
|
||
case m.done:
|
||
b.WriteString("\n" + sOK.Render("✓ ") + "The control plane is up. Add a node: " +
|
||
lipgloss.NewStyle().Bold(true).Render("sudo llmgw node add <user>@<address>") + "\n")
|
||
b.WriteString(" " + sDim.Render("Back up "+m.in.pkiDir+": its ca-key.pem signs every node's identity, and nothing recovers it.") + "\n")
|
||
case m.quit:
|
||
b.WriteString("\n" + sDim.Render("stopped; run init again to finish") + "\n")
|
||
case m.running:
|
||
b.WriteString("\n" + sDim.Render("ctrl+c to stop") + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// visibleLines are the last lines, enough to follow without the screen
|
||
// outgrowing the terminal: the whole log is printed when it closes.
|
||
func (m initModel) visibleLines() []string {
|
||
const keep = 16
|
||
if len(m.lines) > keep {
|
||
return m.lines[len(m.lines)-keep:]
|
||
}
|
||
return m.lines
|
||
}
|
||
|
||
func (m initModel) lineView(l string) string {
|
||
t := strings.TrimSpace(l)
|
||
word, rest, _ := strings.Cut(t, " ")
|
||
rest = truncate(strings.TrimSpace(rest), max(20, m.width-6))
|
||
switch word {
|
||
case "ok":
|
||
return " " + sOK.Render("✓") + " " + rest
|
||
case "kept":
|
||
return " " + sDim.Render("· "+rest)
|
||
case "skip":
|
||
return " " + sDim.Render("- "+rest)
|
||
case "!!":
|
||
return " " + sWarn.Render("! ") + rest
|
||
case "..":
|
||
if m.done {
|
||
return " " + sDim.Render("· "+rest)
|
||
}
|
||
return " " + m.spin.View() + " " + rest
|
||
}
|
||
return " " + t
|
||
}
|
||
|
||
func (m initModel) formView() string {
|
||
var b strings.Builder
|
||
label := func(i int, s string) string {
|
||
if m.focus == i {
|
||
return sTitle.Render("› " + s)
|
||
}
|
||
return " " + s
|
||
}
|
||
check := func(i int, s, hint string) string {
|
||
box := "[ ]"
|
||
if m.toggles[i] {
|
||
box = "[" + sOK.Render("x") + "]"
|
||
}
|
||
return fmt.Sprintf("%s %s %s\n", label(i, box), s, sDim.Render(hint))
|
||
}
|
||
fmt.Fprintf(&b, "%s\n %s\n", label(fAdmin, "Administrator's email"), m.admin.View())
|
||
b.WriteString(" " + sDim.Render("A password is generated and shown once. Leave empty to create accounts later.") + "\n\n")
|
||
fmt.Fprintf(&b, "%s\n %s\n", label(fHosts, "Other names nodes will reach this machine by"), m.hosts.View())
|
||
found := "none found"
|
||
if len(m.found) > 0 {
|
||
found = strings.Join(m.found, ", ")
|
||
}
|
||
b.WriteString(" " + sDim.Render("Already in the certificate: "+found+". Add a DNS name or NAT address here.") + "\n\n")
|
||
b.WriteString(check(fPostgres, "PostgreSQL", "rather than SQLite; installed here, for several control planes or a larger store"))
|
||
if m.in.mirrorURL != "" {
|
||
b.WriteString(" Model mirror " + sDim.Render("at "+m.in.mirrorURL+", from --mirror") + "\n")
|
||
} else {
|
||
b.WriteString(check(fMirror, "Model mirror", "downloads model files once for every node"))
|
||
}
|
||
b.WriteString(check(fNode, "This machine as a node", "serves models here too, on its GPUs or CPU"))
|
||
b.WriteString(check(fModel, "Starter model", "a small Qwen, so there is something to ask at once"))
|
||
b.WriteString("\n" + label(fStart, lipgloss.NewStyle().Bold(true).Render("Set up")) + "\n")
|
||
if m.formError != "" {
|
||
b.WriteString("\n" + sBad.Render(m.formError) + "\n")
|
||
}
|
||
b.WriteString("\n" + sDim.Render("tab or ↑↓ to move · space to tick · enter on Set up to start · ctrl+c to leave") + "\n")
|
||
return b.String()
|
||
}
|
||
|
||
// runInitTUI runs init with the screen above, asking the form first when
|
||
// the flags left the administrator unset.
|
||
func (in *serverInit) runInitTUI(ctx context.Context, out io.Writer) error {
|
||
var found []string
|
||
for _, ip := range localAddrs() {
|
||
found = append(found, ip.String())
|
||
}
|
||
m := newInitModel(in, found, in.admin == "")
|
||
var p *tea.Program
|
||
|
||
var (
|
||
logMu sync.Mutex
|
||
log []string
|
||
)
|
||
w := &lineWriter{send: func(l string) {
|
||
logMu.Lock()
|
||
log = append(log, l)
|
||
logMu.Unlock()
|
||
p.Send(initLineMsg(l))
|
||
}}
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
defer cancel()
|
||
started := make(chan struct{})
|
||
m.start = func() {
|
||
close(started)
|
||
go func() {
|
||
in.out = w
|
||
err := in.run(ctx)
|
||
p.Send(initDoneMsg{err})
|
||
}()
|
||
}
|
||
p = tea.NewProgram(m, tea.WithOutput(out))
|
||
final, err := p.Run()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fm := final.(initModel)
|
||
select {
|
||
case <-started:
|
||
default:
|
||
return fmt.Errorf("left before anything was done")
|
||
}
|
||
if fm.quit && !fm.done {
|
||
cancel()
|
||
return fmt.Errorf("stopped; run init again to finish what was started")
|
||
}
|
||
|
||
// The screen showed the last lines; the whole run goes to the terminal's
|
||
// history, the password with it.
|
||
logMu.Lock()
|
||
defer logMu.Unlock()
|
||
fmt.Fprintln(out)
|
||
for _, l := range log {
|
||
fmt.Fprintln(out, l)
|
||
}
|
||
return fm.err
|
||
}
|