first commit
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
// Package oscmd holds small helpers shared by modules that shell out to system
|
||||
// tools (hostnamectl, timedatectl, systemctl, …): a command runner that
|
||||
// surfaces stderr, and the common "status: ok" HTTP response.
|
||||
package oscmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cmdTimeout caps how long a synchronous system command may run before it is
|
||||
// killed, so a wedged tool can't tie up a request indefinitely.
|
||||
//
|
||||
// It bounds hangs for the plain Run path (which uses context.Background()).
|
||||
// RunContext additionally propagates client cancellation. Long ops (package
|
||||
// install/upgrade) use the streaming runners below, which take a ctx and are
|
||||
// uncapped.
|
||||
const cmdTimeout = 60 * time.Second
|
||||
|
||||
// CommandRunner allows overriding the command execution function for testing.
|
||||
var CommandRunner = func(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
mockMu.Lock()
|
||||
handler, ok := mockCmds[name]
|
||||
mockMu.Unlock()
|
||||
|
||||
if ok {
|
||||
mockRes := handler(args)
|
||||
tmpFile, err := os.CreateTemp("", "nadir-mock-*.json")
|
||||
if err != nil {
|
||||
log.Printf("oscmd mock: failed to create temp file: %v", err)
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
}
|
||||
|
||||
encoder := json.NewEncoder(tmpFile)
|
||||
if err := encoder.Encode(mockRes); err != nil {
|
||||
log.Printf("oscmd mock: failed to write json: %v", err)
|
||||
tmpFile.Close()
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
tmpFile.Close()
|
||||
|
||||
cmd := exec.CommandContext(ctx, os.Args[0])
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_WANT_HELPER_PROCESS=1",
|
||||
"NADIR_MOCK_FILE="+tmpPath,
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
}
|
||||
|
||||
// Run executes name with args and returns trimmed stdout. On failure it wraps
|
||||
// the command's stderr (falling back to the exec error) so handlers can surface
|
||||
// a meaningful message instead of a bare "exit status 1".
|
||||
//
|
||||
// Run uses context.Background(): it is bounded by cmdTimeout but not tied to any
|
||||
// request. Callers running a slow command on behalf of a request (so a client
|
||||
// disconnect should kill it) should use RunContext instead.
|
||||
func Run(name string, args ...string) (string, error) {
|
||||
return RunContext(context.Background(), name, args...)
|
||||
}
|
||||
|
||||
// RunContext is Run with a caller-supplied context. The command is killed when
|
||||
// ctx is cancelled (e.g. the client disconnected) or after cmdTimeout, whichever
|
||||
// comes first.
|
||||
func RunContext(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, cmdTimeout)
|
||||
defer cancel()
|
||||
cmd := CommandRunner(ctx, name, args...)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
||||
return "", errors.New(msg)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// RunStdin is Run with data fed to the command's stdin. Use it for secrets
|
||||
// (e.g. piping "user:password" to chpasswd) so they never appear in argv/ps.
|
||||
func RunStdin(stdin, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
|
||||
defer cancel()
|
||||
cmd := CommandRunner(ctx, name, args...)
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
||||
return "", errors.New(msg)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// RunLines runs the command and splits stdout into non-empty lines.
|
||||
func RunLines(name string, args ...string) ([]string, error) {
|
||||
out, err := Run(name, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
return strings.Split(out, "\n"), nil
|
||||
}
|
||||
|
||||
// RunStatus runs name and returns trimmed stdout plus the process exit code. It
|
||||
// does NOT treat a non-zero exit as an error, because some tools signal state
|
||||
// that way (dnf `check-update` exits 100 when updates exist; pacman `-Qu` exits
|
||||
// 1 when there are none). err is set only when the command could not be run.
|
||||
func RunStatus(name string, args ...string) (string, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cmdTimeout)
|
||||
defer cancel()
|
||||
cmd := CommandRunner(ctx, name, args...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return strings.TrimSpace(string(out)), ee.ExitCode(), nil
|
||||
}
|
||||
return "", -1, err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), 0, nil
|
||||
}
|
||||
|
||||
// RunStream runs a long-lived command (e.g. `journalctl -f`) and pushes its
|
||||
// stdout lines onto the returned channel until the process exits or ctx is
|
||||
// cancelled. Cancelling ctx kills the process (exec.CommandContext) and closes
|
||||
// the channel, so an SSE handler can stop simply by cancelling its request
|
||||
// context (client disconnect).
|
||||
func RunStream(ctx context.Context, name string, args ...string) (<-chan string, error) {
|
||||
cmd := CommandRunner(ctx, name, args...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ch := make(chan string)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
defer cmd.Wait() // reap; process is already killed on ctx cancel
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024) // tolerate long log lines
|
||||
for sc.Scan() {
|
||||
select {
|
||||
case ch <- sc.Text():
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
// A scan error (e.g. a line over the buffer cap) ends the loop silently
|
||||
// otherwise; surface it so a truncated stream is diagnosable. Context
|
||||
// cancellation returns above, so this only fires on a real read error.
|
||||
if err := sc.Err(); err != nil && ctx.Err() == nil {
|
||||
log.Printf("oscmd: stream %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// RunStreamCombined runs a command and streams its merged stdout+stderr line by
|
||||
// line, the way the command's own terminal output looks. Lines are split on \r
|
||||
// as well as \n so progress redraws (apt/dnf) stream instead of buffering until
|
||||
// a newline. extraEnv is appended to the process environment.
|
||||
//
|
||||
// It returns a lines channel and a one-shot error channel that delivers the
|
||||
// process's exit status after the lines channel closes (nil = success).
|
||||
// Cancelling ctx kills the process.
|
||||
func RunStreamCombined(ctx context.Context, extraEnv []string, name string, args ...string) (<-chan string, <-chan error, error) {
|
||||
cmd := CommandRunner(ctx, name, args...)
|
||||
if len(extraEnv) > 0 {
|
||||
if cmd.Env == nil {
|
||||
cmd.Env = append(os.Environ(), extraEnv...)
|
||||
} else {
|
||||
cmd.Env = append(cmd.Env, extraEnv...)
|
||||
}
|
||||
}
|
||||
// One OS pipe for both streams gives the natural interleaving; passing an
|
||||
// *os.File hands the fd straight to the child (no copy goroutine).
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
cmd.Stdout, cmd.Stderr = pw, pw
|
||||
if err := cmd.Start(); err != nil {
|
||||
pr.Close()
|
||||
pw.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
pw.Close() // the child holds its own dup; the reader sees EOF when it exits
|
||||
|
||||
lines := make(chan string)
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(lines)
|
||||
defer func() { errc <- cmd.Wait() }()
|
||||
defer pr.Close()
|
||||
sc := bufio.NewScanner(pr)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
sc.Split(scanLinesCR)
|
||||
for sc.Scan() {
|
||||
if line := sc.Text(); line != "" {
|
||||
select {
|
||||
case lines <- line:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Surface a read error (e.g. a line over the buffer cap) so a truncated
|
||||
// stream is diagnosable. ctx cancellation returns above, so this only
|
||||
// fires on a real error; the exit status still travels via errc.
|
||||
if err := sc.Err(); err != nil && ctx.Err() == nil {
|
||||
log.Printf("oscmd: stream %s: %v", name, err)
|
||||
}
|
||||
}()
|
||||
return lines, errc, nil
|
||||
}
|
||||
|
||||
// scanLinesCR is a bufio.SplitFunc that breaks on either \r or \n, so terminal
|
||||
// progress redraws (carriage returns) surface as they happen.
|
||||
func scanLinesCR(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
if atEOF && len(data) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
|
||||
return i + 1, data[:i], nil
|
||||
}
|
||||
if atEOF {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil // request more data
|
||||
}
|
||||
|
||||
// ParseKV parses key=value lines (e.g. from timedatectl/systemctl show) into a map.
|
||||
func ParseKV(lines []string) map[string]string {
|
||||
m := make(map[string]string, len(lines))
|
||||
for _, line := range lines {
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// StatusOutput is the shared response for write operations that just report
|
||||
// success. Reusing one type means all such endpoints share a single OpenAPI
|
||||
// schema.
|
||||
type StatusOutput struct {
|
||||
Body struct {
|
||||
Status string `json:"status" example:"ok" doc:"Always \"ok\" on success"`
|
||||
}
|
||||
}
|
||||
|
||||
// OK returns a populated StatusOutput.
|
||||
func OK() *StatusOutput {
|
||||
out := &StatusOutput{}
|
||||
out.Body.Status = "ok"
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Mocking helpers for testing ---------------------------------------------
|
||||
|
||||
// MockCommand holds the behavior for a mocked command.
|
||||
type MockCommand struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Lines []string `json:"lines,omitempty"`
|
||||
DelayMs int `json:"delay_ms,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
mockMu sync.Mutex
|
||||
mockCmds = make(map[string]func(args []string) MockCommand)
|
||||
)
|
||||
|
||||
// SetMock registers a mock handler function for the given command name.
|
||||
func SetMock(name string, handler func(args []string) MockCommand) {
|
||||
mockMu.Lock()
|
||||
defer mockMu.Unlock()
|
||||
mockCmds[name] = handler
|
||||
}
|
||||
|
||||
// ClearMocks removes all registered mock command handlers.
|
||||
func ClearMocks() {
|
||||
mockMu.Lock()
|
||||
defer mockMu.Unlock()
|
||||
clear(mockCmds)
|
||||
}
|
||||
|
||||
// RunHelperProcess executes the mock helper process logic if GO_WANT_HELPER_PROCESS is set.
|
||||
// It returns true if it ran (and exits the process), false otherwise.
|
||||
func RunHelperProcess() bool {
|
||||
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
|
||||
return false
|
||||
}
|
||||
mockFile := os.Getenv("NADIR_MOCK_FILE")
|
||||
if mockFile == "" {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.Remove(mockFile)
|
||||
|
||||
data, err := os.ReadFile(mockFile)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mock helper: read failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var mock MockCommand
|
||||
if err := json.Unmarshal(data, &mock); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mock helper: unmarshal failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if len(mock.Lines) > 0 {
|
||||
for _, line := range mock.Lines {
|
||||
fmt.Fprintln(os.Stdout, line)
|
||||
if mock.DelayMs > 0 {
|
||||
time.Sleep(time.Duration(mock.DelayMs) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if mock.Stdout != "" {
|
||||
fmt.Fprint(os.Stdout, mock.Stdout)
|
||||
}
|
||||
if mock.Stderr != "" {
|
||||
fmt.Fprint(os.Stderr, mock.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
os.Exit(mock.ExitCode)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package oscmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if RunHelperProcess() {
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestRunTrimsStdout(t *testing.T) {
|
||||
out, err := Run("echo", "hello")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "hello" {
|
||||
t.Errorf("got %q, want %q", out, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSurfacesStderr(t *testing.T) {
|
||||
_, err := Run("sh", "-c", "echo boom >&2; exit 1")
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected stderr 'boom' in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMissingBinary(t *testing.T) {
|
||||
if _, err := Run("definitely-not-a-real-binary-xyz"); err == nil {
|
||||
t.Fatal("expected error for missing binary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLines(t *testing.T) {
|
||||
got, err := RunLines("printf", `a\nb\nc\n`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"a", "b", "c"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLinesEmpty(t *testing.T) {
|
||||
got, err := RunLines("true")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("empty output should yield no lines, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOK(t *testing.T) {
|
||||
if OK().Body.Status != "ok" {
|
||||
t.Error("OK() should report status ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStdin(t *testing.T) {
|
||||
SetMock("cat", func(args []string) MockCommand {
|
||||
return MockCommand{Stdout: "piped input data", ExitCode: 0}
|
||||
})
|
||||
defer ClearMocks()
|
||||
|
||||
got, err := RunStdin("some input", "cat")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "piped input data" {
|
||||
t.Errorf("got %q, want %q", got, "piped input data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatus(t *testing.T) {
|
||||
SetMock("check-update", func(args []string) MockCommand {
|
||||
return MockCommand{Stdout: "updates available", ExitCode: 100}
|
||||
})
|
||||
defer ClearMocks()
|
||||
|
||||
out, code, err := RunStatus("check-update")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out != "updates available" || code != 100 {
|
||||
t.Errorf("got out=%q code=%d, want out=%q code=100", out, code, "updates available")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStream(t *testing.T) {
|
||||
SetMock("stream-cmd", func(args []string) MockCommand {
|
||||
return MockCommand{Lines: []string{"line1", "line2"}, DelayMs: 1}
|
||||
})
|
||||
defer ClearMocks()
|
||||
|
||||
ch, err := RunStream(t.Context(), "stream-cmd")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got []string
|
||||
for line := range ch {
|
||||
got = append(got, line)
|
||||
}
|
||||
|
||||
want := []string{"line1", "line2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStreamCombined(t *testing.T) {
|
||||
SetMock("combined-cmd", func(args []string) MockCommand {
|
||||
return MockCommand{Lines: []string{"output1", "output2"}, ExitCode: 0}
|
||||
})
|
||||
defer ClearMocks()
|
||||
|
||||
lines, errc, err := RunStreamCombined(t.Context(), nil, "combined-cmd")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got []string
|
||||
for line := range lines {
|
||||
got = append(got, line)
|
||||
}
|
||||
|
||||
if err := <-errc; err != nil {
|
||||
t.Errorf("stream failed with error: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"output1", "output2"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user