From fa450dd130ef45b6ce9146bf3d3b9177bf295965 Mon Sep 17 00:00:00 2001 From: Ian Fijolek Date: Mon, 21 Oct 2024 12:29:17 -0700 Subject: [PATCH] Initial commit --- .gitignore | 29 ++++++ .golangci.yml | 36 +++++++ .pre-commit-config.yaml | 16 +++ LICENSE | 8 ++ Makefile | 27 +++++ README.md | 3 + go.mod | 3 + main.go | 217 ++++++++++++++++++++++++++++++++++++++++ main_test.go | 122 ++++++++++++++++++++++ 9 files changed, 461 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 main.go create mode 100644 main_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7869c4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# ---> Go +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof + +.DS_Store +coverage.out +dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..286bf03 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,36 @@ +--- +linters: + enable: + - errname + - errorlint + - exhaustive + - gofumpt + - goimports + - gomnd + - goprintffuncname + - misspell + - tagliatelle + - tenv + - testpackage + - thelper + - tparallel + - unconvert + - wrapcheck + - wsl + disable: + - gochecknoglobals + +linters-settings: + gosec: + excludes: + - G204 + tagliatelle: + case: + rules: + yaml: snake + +issues: + exclude-rules: + - path: _test\.go + linters: + - gosec diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..37ef62f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-yaml + args: + - --allow-multiple-documents + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - repo: https://github.com/golangci/golangci-lint + rev: v1.52.2 + hooks: + - id: golangci-lint diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..472ac23 --- /dev/null +++ b/LICENSE @@ -0,0 +1,8 @@ +MIT License +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0ec0010 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +GOFILES = *.go go.mod + +# Default make target will run tests +.DEFAULT_GOAL = test + +# Run all tests +.PHONY: test +test: + go test -coverprofile=coverage.out + go tool cover -func=coverage.out + @go tool cover -func=coverage.out | awk -v target=50.0% \ + '/^total:/ { print "Total coverage: " $$3 " Minimum coverage: " target; if ($$3+0.0 >= target+0.0) print "ok"; else { print "fail"; exit 1; } }' + +# Installs pre-commit hooks +.PHONY: install-hooks +install-hooks: + pre-commit install --install-hooks + +# Runs pre-commit checks on files +.PHONY: check +check: + pre-commit run --all-files + +.PHONY: clean +clean: + rm -f ./coverage.out + rm -fr ./dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..1836ff1 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Go Shell Runner + +Library for asyncronously executing shell commands in Go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..14a150f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.iamthefij.com/iamthefij/go-shell-runner + +go 1.21.4 diff --git a/main.go b/main.go new file mode 100644 index 0000000..606ee43 --- /dev/null +++ b/main.go @@ -0,0 +1,217 @@ +package shellrunner + +import ( + "bytes" + "context" + "errors" + "os/exec" + "sync" + "time" +) + +const ( + MAX_RESULTS = 100 +) + +// CommandResult holds the result of a shell command execution. +type CommandResult struct { + Command string + ReturnCode int + Output string + ErrOutput string +} + +// ShellRunner manages shell command execution. +type ShellRunner struct { + cmdQueue chan func() *CommandResult + results chan *CommandResult + stopChan chan struct{} + running sync.WaitGroup + callbacks chan func() + shell string // Private field to store the shell + mu sync.Mutex + isStopped bool + activeCmds map[*exec.Cmd]struct{} // Track active commands for cancellation +} + +// NewShellRunner creates a new ShellRunner instance with the default shell (`sh`). +func NewShellRunner() *ShellRunner { + return NewShellRunnerWithShell("sh") +} + +// NewShellRunnerWithShell creates a new ShellRunner with the specified shell. +func NewShellRunnerWithShell(shell string) *ShellRunner { + return &ShellRunner{ + cmdQueue: make(chan func() *CommandResult), + results: make(chan *CommandResult, MAX_RESULTS), + stopChan: make(chan struct{}), + callbacks: make(chan func(), MAX_RESULTS), + shell: shell, + activeCmds: make(map[*exec.Cmd]struct{}), + } +} + +// Start begins processing shell commands asynchronously. +func (sr *ShellRunner) Start() { + go func() { + for { + select { + case <-sr.stopChan: + return + case cmdFunc, ok := <-sr.cmdQueue: + if !ok { + return + } + + sr.running.Add(1) + + go func() { + result := cmdFunc() + sr.results <- result + sr.running.Done() + }() + case callback := <-sr.callbacks: + callback() + } + } + }() +} + +// AddCommand adds a shell command to be executed with an optional callback. +// No commands can be added if the runner has been stopped. +func (sr *ShellRunner) AddCommand(command string, callback func(*CommandResult)) error { + sr.mu.Lock() + defer sr.mu.Unlock() + + if sr.isStopped { + return errors.New("runner has been stopped, cannot add new commands") + } + + sr.cmdQueue <- func() *CommandResult { + result := sr.executeCommand(command) + if callback != nil { + sr.callbacks <- func() { + callback(result) + } + } + return result + } + + return nil +} + +// GetResults retrieves the next available command result (non-blocking). +func (sr *ShellRunner) GetResults() *CommandResult { + select { + case result := <-sr.results: + return result + default: + return nil + } +} + +// Stop gracefully stops the ShellRunner, closing the command queue and waiting for all commands to finish. +func (sr *ShellRunner) Stop() { + sr.mu.Lock() + if sr.isStopped { + sr.mu.Unlock() + return + } + + sr.isStopped = true + + close(sr.cmdQueue) // No more commands can be added + close(sr.stopChan) + sr.mu.Unlock() + + sr.running.Wait() +} + +// Kill stops the ShellRunner immediately, terminating all running commands. +func (sr *ShellRunner) Kill() { + sr.mu.Lock() + if sr.isStopped { + sr.mu.Unlock() + return + } + + sr.isStopped = true + + close(sr.cmdQueue) // Prevent further commands + close(sr.stopChan) + + // Terminate all active commands + for cmd := range sr.activeCmds { + _ = cmd.Process.Kill() + } + sr.mu.Unlock() + + sr.running.Wait() +} + +// KillWithTimeout attempts to stop the ShellRunner, killing commands if the duration is exceeded. +func (sr *ShellRunner) KillWithTimeout(timeout time.Duration) error { + done := make(chan struct{}) + go func() { + sr.Stop() + close(done) + }() + + select { + case <-done: + return nil + case <-time.After(timeout): + sr.Kill() + return errors.New("commands killed due to timeout") + } +} + +// executeCommand runs a shell command asynchronously, capturing stdout, stderr, and return code. +func (sr *ShellRunner) executeCommand(command string) *CommandResult { + var outBuf, errBuf bytes.Buffer + + ctx := context.Background() + cmd := exec.CommandContext(ctx, sr.shell, "-c", command) + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + // Track the active command + sr.mu.Lock() + sr.activeCmds[cmd] = struct{}{} + sr.mu.Unlock() + + err := cmd.Start() + if err != nil { + return &CommandResult{ + Command: command, + ReturnCode: -1, + ErrOutput: err.Error(), + } + } + + err = cmd.Wait() + + // Remove from active commands + sr.mu.Lock() + delete(sr.activeCmds, cmd) + sr.mu.Unlock() + + result := &CommandResult{ + Command: command, + Output: outBuf.String(), + ErrOutput: errBuf.String(), + } + + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ReturnCode = exitErr.ExitCode() + } else { + result.ReturnCode = -1 + } + } else { + result.ReturnCode = 0 + } + + return result +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..5147a91 --- /dev/null +++ b/main_test.go @@ -0,0 +1,122 @@ +package shellrunner_test + +import ( + "testing" + "time" + + shellrunner "git.iamthefij.com/iamthefij/go-shell-runner" +) + +func TestShellRunnerNoCallback(t *testing.T) { + cases := []struct { + command string + output string + ReturnCode int + }{ + {"echo hello world", "hello world\n", 0}, + {"echo hello world && exit 1", "hello world\n", 1}, + } + + for _, c := range cases { + c := c + t.Run(c.command, func(t *testing.T) { + t.Parallel() + + runner := shellrunner.NewShellRunner() + runner.Start() + + // Test command without callback + if err := runner.AddCommand(c.command, nil); err != nil { + t.Fatalf("unexpected error adding command: %v", err) + } + + runner.Stop() + + result := runner.GetResults() + if result == nil || result.Output != c.output || result.ReturnCode != c.ReturnCode { + t.Fatalf("expected output '%s' and return code %d, got '%s' and %d", c.output, c.ReturnCode, result.Output, result.ReturnCode) + } + }) + } +} + +func TestShellRunnerCallback(t *testing.T) { + t.Parallel() + + runner := shellrunner.NewShellRunner() + runner.Start() + + // Test command with callback + done := make(chan struct{}) + + callbackReached := false + + if err := runner.AddCommand("echo callback test", func(result *shellrunner.CommandResult) { + callbackReached = true + if result.Output != "callback test\n" { + t.Fatalf("expected 'callback test', got '%s'", result.Output) + } + close(done) + }); err != nil { + t.Fatalf("unexpected error adding command: %v", err) + } + + // Timeout waiting for callback + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("callback timed out") + } + + if !callbackReached { + t.Fatal("callback was not reached") + } + + runner.Stop() + + // Make sure stop and kill both exit gracefully after the runner is stopped + runner.Stop() + runner.Stop() +} + +func TestShellRunnerKillWithTimeout(t *testing.T) { + t.Parallel() + + runner := shellrunner.NewShellRunner() + runner.Start() + + // Test command with callback + callbackReached := false + + if err := runner.AddCommand("sleep 10 && echo callback test", func(result *shellrunner.CommandResult) { + callbackReached = true + if result.Output != "callback test\n" { + t.Fatalf("expected 'callback test', got '%s'", result.Output) + } + }); err != nil { + t.Fatalf("unexpected error adding command: %v", err) + } + + // Wait one second to make sure the command starts running + time.Sleep(1 * time.Second) + + if err := runner.KillWithTimeout(1 * time.Second); err == nil { + t.Fatal("expected error when killing commands, but got none") + } + + if callbackReached { + t.Fatal("callback was reached before kill") + } +} + +func TestStopPreventsNewCommands(t *testing.T) { + runner := shellrunner.NewShellRunner() + runner.Start() + + runner.Stop() + + err := runner.AddCommand("echo should not run", nil) + if err == nil { + t.Fatal("expected error when adding command after stop, but got none") + } +}