Skip to content
Merged
80 changes: 26 additions & 54 deletions checks/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,55 +2,41 @@ package checks

import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"os"
"os/exec"
"regexp"
"runtime"
"strings"
"time"

api "github.com/bootdotdev/bootdev/client"
)

const (
cliCommandTimeout = 5 * time.Minute
maxCLIOutputBytesPerStream = 1024 * 1024
commandWaitDelay = 2 * time.Second
)

var errCLIOutputLimitExceeded = errors.New("CLI command output limit exceeded")
const maxCLIOutputBytesPerStream = 1024 * 1024

type boundedBuffer struct {
buffer bytes.Buffer
limit int
truncated bool
onTruncate func()
buffer bytes.Buffer
limit int
truncated bool
}

func newBoundedBuffer(limit int, onTruncate func()) *boundedBuffer {
return &boundedBuffer{
limit: max(limit, 0),
onTruncate: onTruncate,
}
func newBoundedBuffer(limit int) *boundedBuffer {
return &boundedBuffer{limit: max(limit, 0)}
}

func (b *boundedBuffer) Write(p []byte) (int, error) {
originalLength := len(p)

remaining := max(b.limit-b.buffer.Len(), 0)
toWrite := min(len(p), remaining)
if toWrite > 0 {
_, _ = b.buffer.Write(p[:toWrite])
}
if toWrite < len(p) && !b.truncated {
if toWrite < len(p) {
b.truncated = true
if b.onTruncate != nil {
b.onTruncate()
}
}

return originalLength, nil
}

Expand All @@ -59,71 +45,52 @@ func (b *boundedBuffer) String() string {
}

func runCLICommand(command api.CLIStepCLICommand, variables map[string]string) (result api.CLICommandResult) {
return runCLICommandWithLimits(command, variables, cliCommandTimeout, maxCLIOutputBytesPerStream)
return runCLICommandWithOutputLimit(command, variables, maxCLIOutputBytesPerStream)
}

func runCLICommandWithLimits(
func runCLICommandWithOutputLimit(
command api.CLIStepCLICommand,
variables map[string]string,
timeout time.Duration,
maxOutputBytesPerStream int,
) (result api.CLICommandResult) {
finalCommand := InterpolateVariables(command.Command, variables)
result.FinalCommand = finalCommand
result.Command = command

timeoutCtx, cancelTimeout := context.WithTimeout(context.Background(), timeout)
defer cancelTimeout()
ctx, cancelCommand := context.WithCancelCause(timeoutCtx)
defer cancelCommand(nil)

var cmd *exec.Cmd

if runtime.GOOS == "windows" {
cmd = exec.CommandContext(ctx, "powershell", "-Command", finalCommand)
cmd = exec.Command("powershell", "-Command", finalCommand)
} else {
cmd = exec.CommandContext(ctx, "sh", "-c", finalCommand)
cmd = exec.Command("sh", "-c", finalCommand)
}

configureCommandCancellation(cmd)
cmd.Env = append(os.Environ(), "LANG=en_US.UTF-8")
cmd.WaitDelay = commandWaitDelay
cancelForOutputLimit := func() {
cancelCommand(errCLIOutputLimitExceeded)
}
stdout := newBoundedBuffer(maxOutputBytesPerStream, cancelForOutputLimit)
stderr := newBoundedBuffer(maxOutputBytesPerStream, cancelForOutputLimit)
stdout := newBoundedBuffer(maxOutputBytesPerStream)
stderr := newBoundedBuffer(maxOutputBytesPerStream)
cmd.Stdout = stdout
cmd.Stderr = stderr
stopSignalForwarding := forwardSignalsToCommand(cmd)
defer stopSignalForwarding()

err := cmd.Run()
if ee, ok := err.(*exec.ExitError); ok {
result.ExitCode = ee.ExitCode()
} else if err != nil {
result.ExitCode = -2
}

result.Stdout = strings.TrimRight(stdout.String(), " \n\t\r")
result.Stderr = strings.TrimRight(stderr.String(), " \n\t\r")
if command.StdoutFilterTmdl != nil {
result.Stdout = ExtractTmdlBlock(result.Stdout, *command.StdoutFilterTmdl)
}

switch {
case errors.Is(context.Cause(ctx), errCLIOutputLimitExceeded):
if stdout.truncated || stderr.truncated {
result.Err = fmt.Sprintf("command output exceeded the %d-byte per-stream limit", maxOutputBytesPerStream)
result.ExitCode = -2
case errors.Is(context.Cause(ctx), context.DeadlineExceeded):
result.Err = fmt.Sprintf("command timed out after %s", timeout)
result.ExitCode = -2
}

if result.Err == "" {
if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, variables); err != nil {
result.Err = err.Error()
}
} else if err := parseStdoutVariables(result.Stdout, command.StdoutVariables, variables); err != nil {
result.Err = err.Error()
}
result.Variables = maps.Clone(variables)

return result
}

Expand Down Expand Up @@ -156,9 +123,11 @@ func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string)
if test.ExitCode != nil {
return fmt.Sprintf("Expect exit code %d", *test.ExitCode)
}

if test.StdoutLinesGT != nil {
return fmt.Sprintf("Expect > %d lines on stdout", *test.StdoutLinesGT)
}

if test.StdoutContainsAll != nil {
var str strings.Builder
str.WriteString("Expect stdout to contain all of:")
Expand All @@ -168,6 +137,7 @@ func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string)
}
return str.String()
}

if test.StdoutContainsNone != nil {
var str strings.Builder
str.WriteString("Expect stdout to contain none of:")
Expand All @@ -177,8 +147,10 @@ func prettyPrintCLICommand(test api.CLICommandTest, variables map[string]string)
}
return str.String()
}

if test.StdoutJq != nil {
return prettyPrintStdoutJqTest(*test.StdoutJq, variables)
}

return ""
}
39 changes: 3 additions & 36 deletions checks/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,18 @@ import (
"runtime"
"strings"
"testing"
"time"

api "github.com/bootdotdev/bootdev/client"
)

func TestRunCLICommandTimesOut(t *testing.T) {
command := `while :; do :; done`
if runtime.GOOS == "windows" {
command = `while ($true) {}`
}

start := time.Now()
result := runCLICommandWithLimits(
api.CLIStepCLICommand{Command: command},
map[string]string{},
20*time.Millisecond,
1024,
)
elapsed := time.Since(start)

if !strings.Contains(result.Err, "command timed out") {
t.Fatalf("command error = %q, want timeout error", result.Err)
}
if result.ExitCode >= 0 {
t.Fatalf("exit code = %d, want internal failure", result.ExitCode)
}
if elapsed > time.Second {
t.Fatalf("command took %v, want a prompt timeout", elapsed)
}
}

func TestRunCLICommandCapsOutput(t *testing.T) {
command := `printf 'abcdefgh'; while :; do :; done`
command := `printf 'abcdefgh'`
if runtime.GOOS == "windows" {
command = `[Console]::Out.Write('abcdefgh'); while ($true) {}`
command = `[Console]::Out.Write('abcdefgh')`
}

variables := map[string]string{}
start := time.Now()
result := runCLICommandWithLimits(
result := runCLICommandWithOutputLimit(
api.CLIStepCLICommand{
Command: command,
StdoutVariables: []api.CLICommandStdoutVariable{{
Expand All @@ -52,10 +24,8 @@ func TestRunCLICommandCapsOutput(t *testing.T) {
}},
},
variables,
5*time.Second,
4,
)
elapsed := time.Since(start)

if !strings.Contains(result.Err, "per-stream limit") {
t.Fatalf("command error = %q, want per-stream output limit error", result.Err)
Expand All @@ -69,9 +39,6 @@ func TestRunCLICommandCapsOutput(t *testing.T) {
if _, ok := variables["partial"]; ok {
t.Fatal("truncated output unexpectedly populated a stdout variable")
}
if elapsed > time.Second {
t.Fatalf("command took %v, want cancellation immediately after exceeding the output limit", elapsed)
}
}

func TestRunCLICommandCapturesStdoutVariables(t *testing.T) {
Expand Down
11 changes: 0 additions & 11 deletions checks/command_process_other.go

This file was deleted.

63 changes: 0 additions & 63 deletions checks/command_process_unix.go

This file was deleted.

52 changes: 0 additions & 52 deletions checks/command_process_unix_test.go

This file was deleted.

Loading