Initial commit
This commit is contained in:
commit
fa450dd130
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -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/
|
36
.golangci.yml
Normal file
36
.golangci.yml
Normal file
@ -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
|
16
.pre-commit-config.yaml
Normal file
16
.pre-commit-config.yaml
Normal file
@ -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
|
8
LICENSE
Normal file
8
LICENSE
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
MIT License
|
||||||
|
Copyright (c) <year> <copyright holders>
|
||||||
|
|
||||||
|
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.
|
27
Makefile
Normal file
27
Makefile
Normal file
@ -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
|
3
README.md
Normal file
3
README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Go Shell Runner
|
||||||
|
|
||||||
|
Library for asyncronously executing shell commands in Go
|
3
go.mod
Normal file
3
go.mod
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
module git.iamthefij.com/iamthefij/go-shell-runner
|
||||||
|
|
||||||
|
go 1.21.4
|
217
main.go
Normal file
217
main.go
Normal file
@ -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
|
||||||
|
}
|
122
main_test.go
Normal file
122
main_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user