package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"os/exec"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// logLineMsg is one line of output from a running step's subprocess,
// tagged with which step it belongs to so the right log panel picks it up
// (several screens have their own scrolling log — see screens_deploy.go).
type logLineMsg struct {
stream string
line string
}
// stepResultMsg reports a step's subprocess finishing, successfully or
// not — every long-running action (installing k3s, building the image,
// applying manifests...) ends in exactly one of these.
type stepResultMsg struct {
stream string
err error
}
// listen drains the shared message channel one value at a time — every
// async operation in this program (subprocess output, scan results, DNS
// checks...) funnels through msgCh, and Update re-issues listen(msgCh)
// after handling each message to keep receiving. This is the standard
// Bubbletea pattern for an external event source feeding the UI (see e.g.
// the bubbletea "realtime" example) — one channel, one listener loop,
// rather than a bespoke plumbing per screen.
func listen(ch chan tea.Msg) tea.Cmd {
return func() tea.Msg { return <-ch }
}
// runStreamed runs name(args...) with dir as its working directory (empty
// for the current one), sending each line of its combined output as a
// logLineMsg tagged stream, then exactly one stepResultMsg when it exits.
// Meant to be launched with `go runStreamed(...)` from a tea.Cmd — it
// blocks, so it must never run on Bubbletea's own update goroutine.
//
// Also returns the same error it put in the stepResultMsg, so a multi-
// command sequence (runInstallDeps, runBuildImage, runDeploy) can bail out
// after the first failure instead of ploughing on into commands that can
// only fail more confusingly because an earlier one never finished.
func runStreamed(ctx context.Context, ch chan<- tea.Msg, stream, dir, name string, args ...string) error {
cmd := exec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
stdout, err := cmd.StdoutPipe()
if err != nil {
ch <- stepResultMsg{stream: stream, err: err}
return err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
ch <- stepResultMsg{stream: stream, err: err}
return err
}
scanner := bufio.NewScanner(stdout)
// Docker build progress lines can be long (full build-context paths);
// the default 64KiB scanner buffer is already generous, but bump it
// so a single unusually long line never aborts the scan mid-stream.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
ch <- logLineMsg{stream: stream, line: scanner.Text()}
}
err = cmd.Wait()
ch <- stepResultMsg{stream: stream, err: err}
return err
}
// runQuiet runs name(args...) and returns its combined output and error,
// for the (frequent) case a step needs a command's result but not a live
// log of it — e.g. a single `kubectl get pod` status poll.
func runQuiet(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
out, err := cmd.CombinedOutput()
return string(out), err
}
// runPiped runs first | second without a shell — first's stdout feeds
// second's stdin directly through an in-process pipe. This replaces a
// former `sh -c "cmd1 '<arg>' | cmd2"` pattern whose quoting (a bare wrap
// in single quotes) didn't escape an embedded single quote, letting a
// crafted argument (e.g. a VERSION file containing one) break out and
// inject arbitrary shell commands. No shell involved here, so there's
// nothing to escape.
func runPiped(ctx context.Context, first, second []string) error {
c1 := exec.CommandContext(ctx, first[0], first[1:]...)
c2 := exec.CommandContext(ctx, second[0], second[1:]...)
pr, pw := io.Pipe()
c1.Stdout = pw
c2.Stdin = pr
var stderr1, stderr2 bytes.Buffer
c1.Stderr = &stderr1
c2.Stderr = &stderr2
if err := c2.Start(); err != nil {
return fmt.Errorf("%s: %w", second[0], err)
}
if err := c1.Start(); err != nil {
return fmt.Errorf("%s: %w", first[0], err)
}
err1 := c1.Wait()
pw.Close()
err2 := c2.Wait()
if err1 != nil {
return fmt.Errorf("%s: %w: %s", first[0], err1, strings.TrimSpace(stderr1.String()))
}
if err2 != nil {
return fmt.Errorf("%s: %w: %s", second[0], err2, strings.TrimSpace(stderr2.String()))
}
return nil
}