Gitfed
bastien-mrq/gitfed / cmd / gitfed-install / exec.go
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
}