Files
llmgw/internal/cli/tui_init.go
Ilker ManapandClaude Opus 5.5 bae16426a2 Offline sites and Kubernetes: llmgw bundle create, import and trust, offline.mirror, server init --bundle; a Helm chart for the control plane
A bundle carries what a cluster would download — llmgw's release with its
signed checksums, every llama.cpp build per architecture with GitHub's
checksums, model files pinned and checked against Hugging Face's — into a
directory or a tar whose manifest sha256 is carried apart. Import checks
everything, the release against the signing key, before anything is put in
the mirror. With offline.mirror set, nodes install llama.cpp from the
mirror against checksums the control plane holds and sends over the agent
stream; releases default to the mirror for node add, ctl upgrade and agent
upgrades. server init and the setup form install from a bundle.

The Helm chart runs one control plane with SQLite on a claim or an external
PostgreSQL, Secrets for the agent certificate and admin token, Services,
Ingress and ServiceMonitor; deploy/container/Dockerfile builds its image.
GPU nodes stay outside Kubernetes, as docs/kubernetes.md explains.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:13:39 +02:00

517 lines
14 KiB
Go
Raw Permalink 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"
"path/filepath"
"strings"
"sync"
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
fGuard
fBundle
fBundleSHA
fStart
fCount
)
type initModel struct {
in *serverInit
found []string // this machine's addresses
// The form, until it is submitted.
form bool
focus int
admin textField
hosts textField
bundle textField
bundleSHA textField
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 spinnerModel
width int
height int
// framed draws it in the full-screen frame; off when another screen
// shows it in its own panel.
framed bool
done bool
err error
quit bool
start func()
}
func newInitModel(in *serverInit, found []string, askForm bool) initModel {
admin := newTextField()
admin.Placeholder = "you@example.com"
admin.SetValue(in.admin)
admin.CharLimit = 200
admin.Width = 40
hosts := newTextField()
hosts.Placeholder = "llm.example.com, 203.0.113.10"
hosts.SetValue(strings.Join(in.hosts, ", "))
hosts.CharLimit = 400
hosts.Width = 40
bundle := newTextField()
bundle.Placeholder = "/media/usb/llmgw-bundle.tar"
bundle.SetValue(in.bundle)
bundle.CharLimit = 400
bundle.Width = 40
bundleSHA := newTextField()
bundleSHA.Placeholder = "the sha256 'llmgw bundle create' printed"
bundleSHA.SetValue(in.bundleSHA)
bundleSHA.CharLimit = 64
bundleSHA.Width = 64
sp := newSpinner()
m := initModel{in: in, found: found, form: askForm, admin: admin, hosts: hosts,
bundle: bundle, bundleSHA: bundleSHA, spin: sp, width: 100}
m.toggles[fPostgres] = in.postgres
m.toggles[fMirror] = !in.noMirror
m.toggles[fNode] = !in.noNode
m.toggles[fModel] = !in.noModel
m.toggles[fGuard] = !in.noGuardModel
if askForm {
m.admin.Focus()
}
return m
}
func (m initModel) Init() tea.Cmd {
if m.form {
return nil
}
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, m.height = msg.Width, msg.Height
if m.framed {
m.width = msg.Width - navWidth - 6
}
case spinTickMsg:
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
if m.framed {
return m, nil // left on screen until a key: it is gone once closed
}
return m, tea.Quit
case tea.KeyMsg:
if msg.Type == tea.KeyCtrlC {
m.quit = true
return m, tea.Quit
}
if m.done && m.framed && (msg.Type == tea.KeyEnter || msg.String() == "q") {
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
}
}
bundle, sum := strings.TrimSpace(m.bundle.Value()), strings.ToLower(strings.TrimSpace(m.bundleSHA.Value()))
switch {
case bundle != "" && sum == "":
m.formError = "give the bundle's sha256, as 'llmgw bundle create' printed it"
m.focus = fBundleSHA
m.moveFocus(0)
return m, nil
case bundle != "" && !m.toggles[fMirror] && m.in.mirrorURL == "":
m.formError = "an offline bundle goes into the model mirror: tick it"
m.focus = fMirror
m.moveFocus(0)
return m, nil
}
m.in.bundle, m.in.bundleSHA = bundle, sum
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.in.noGuardModel = !m.toggles[fGuard]
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 fBundle:
var cmd tea.Cmd
m.bundle, cmd = m.bundle.Update(k)
return m, cmd
case fBundleSHA:
var cmd tea.Cmd
m.bundleSHA, cmd = m.bundleSHA.Update(k)
return m, cmd
case fPostgres, fMirror, fNode, fModel, fGuard:
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()
m.bundle.Blur()
m.bundleSHA.Blur()
switch m.focus {
case fAdmin:
m.admin.Focus()
case fHosts:
m.hosts.Focus()
case fBundle:
m.bundle.Focus()
case fBundleSHA:
m.bundleSHA.Focus()
}
}
func (m initModel) View() string {
if !m.framed {
return m.plainView()
}
body := m.plainView()
if i := strings.Index(body, "\n\n"); i >= 0 {
body = body[i+1:]
}
steps := []navItem{{Label: "Start", State: "done"}, {Label: "Settings", State: "current"}, {Label: "Install", State: "todo"}}
keys := "Tab/↑↓ move · Space tick · Enter on Set up · Ctrl+C leave"
if !m.form {
steps[1].State, steps[2].State = "done", "current"
keys = "Ctrl+C stop"
if m.done {
steps[2].State = "done"
keys = "Enter close"
}
}
host, _ := os.Hostname()
return frame{Width: m.width + navWidth + 6, Height: m.height, Title: "llmgw setup", Where: host,
NavTitle: "Steps", Nav: steps, PanelTitle: "This machine as a control plane", Body: body, Keys: keys}.render()
}
// plainView is the screen as text, for a panel or a terminal that is not one.
func (m initModel) plainView() 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(check(fGuard, "Guardrails' judge", "Llama Guard 3 1B on a CPU, refusing violent, criminal and abusive requests"))
fmt.Fprintf(&b, "\n%s\n %s\n", label(fBundle, "Offline bundle"), m.bundle.View())
b.WriteString(" " + sDim.Render("For a site with no internet: engines, releases and models from 'llmgw bundle create'. Empty downloads them.") + "\n")
fmt.Fprintf(&b, "%s\n %s\n", label(fBundleSHA, "Its sha256"), m.bundleSHA.View())
b.WriteString("\n" + label(fStart, lipgloss.NewStyle().Bold(true).Render("Set up")) + "\n")
if m.formError != "" {
b.WriteString("\n" + sBad.Render(m.formError) + "\n")
}
if !m.framed { // the frame's key bar says it
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 == "")
m.framed = true
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), tea.WithAltScreen())
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 whole run is kept, root's only, with the password taken out: it is
// shown once, on the screen, and a file on disk would outlive that. On a
// failure the run is printed too, for the lines the screen had no room
// for; otherwise the screen's last frame says it, and only what must
// outlive the screen is printed again: the password, and what to do next.
logMu.Lock()
defer logMu.Unlock()
whole := strings.Join(log, "\n") + "\n"
kept := whole
if fm.password != "" {
kept = strings.ReplaceAll(kept, fm.password, "[password shown once, not kept]")
}
saved := ""
if in.stateDir != "" {
p := filepath.Join(in.stateDir, "init.log")
if err := os.WriteFile(p, []byte(kept), 0o600); err == nil {
saved = p
}
}
fmt.Fprintln(out)
if fm.err != nil {
fmt.Fprint(out, whole)
return fm.err
}
if fm.password != "" {
fmt.Fprintf(out, "Administrator %s, password %s (shown once)\n\n", in.admin, fm.password)
}
for _, l := range fm.next {
fmt.Fprintln(out, l)
}
if saved != "" {
fmt.Fprintf(out, "\nThe whole run is in %s.\n", saved)
}
return fm.err
}