mirror of https://github.com/databricks/cli.git
Make libs/exec fallback to `sh` if `bash` cannot be found (#1114)
## Changes Falling back to `sh` is also what GitHub Actions do if `bash` is not found in the path. It is possible `bash` is not available when running from minimal Docker containers and we must not error out in this case. See: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsshell. This change renames `interpreter` -> `shell`. ## Tests Unit tests pass.
This commit is contained in:
parent
3c76a11d00
commit
94112eaedb
|
@ -46,23 +46,23 @@ func (c *command) Stderr() io.ReadCloser {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
interpreter interpreter
|
shell shell
|
||||||
dir string
|
dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCommandExecutor(dir string) (*Executor, error) {
|
func NewCommandExecutor(dir string) (*Executor, error) {
|
||||||
interpreter, err := findInterpreter()
|
shell, err := findShell()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Executor{
|
return &Executor{
|
||||||
interpreter: interpreter,
|
shell: shell,
|
||||||
dir: dir,
|
dir: dir,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) StartCommand(ctx context.Context, command string) (Command, error) {
|
func (e *Executor) StartCommand(ctx context.Context, command string) (Command, error) {
|
||||||
ec, err := e.interpreter.prepare(command)
|
ec, err := e.shell.prepare(command)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,8 +2,11 @@ package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
osexec "os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -49,51 +52,63 @@ func TestExecutorWithInvalidCommandWithWindowsLikePath(t *testing.T) {
|
||||||
assert.Contains(t, string(out), "C:\\Program Files\\invalid-command.exe: No such file or directory")
|
assert.Contains(t, string(out), "C:\\Program Files\\invalid-command.exe: No such file or directory")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindBashInterpreterNonWindows(t *testing.T) {
|
func testExecutorWithShell(t *testing.T, shell string) {
|
||||||
if runtime.GOOS == "windows" {
|
p, err := osexec.LookPath(shell)
|
||||||
t.SkipNow()
|
if err != nil {
|
||||||
|
if errors.Is(err, osexec.ErrNotFound) {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
if shell == "cmd" {
|
||||||
|
// We must find `cmd.exe` on Windows.
|
||||||
|
t.Fatal("cmd.exe not found")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if shell == "bash" || shell == "sh" {
|
||||||
|
// We must find `bash` or `sh` on other operating systems.
|
||||||
|
t.Fatal("bash or sh not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Skipf("shell %s not found", shell)
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
interpreter, err := findBashInterpreter()
|
// Create temporary directory with only the shell executable in the PATH.
|
||||||
assert.NoError(t, err)
|
tmpDir := t.TempDir()
|
||||||
assert.NotEmpty(t, interpreter)
|
t.Setenv("PATH", tmpDir)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
os.Symlink(p, fmt.Sprintf("%s/%s.exe", tmpDir, shell))
|
||||||
|
} else {
|
||||||
|
os.Symlink(p, fmt.Sprintf("%s/%s", tmpDir, shell))
|
||||||
|
}
|
||||||
|
|
||||||
e, err := NewCommandExecutor(".")
|
executor, err := NewCommandExecutor(".")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
e.interpreter = interpreter
|
out, err := executor.Exec(context.Background(), "echo 'Hello from shell'")
|
||||||
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
out, err := e.Exec(context.Background(), `echo "Hello from bash"`)
|
assert.NotNil(t, out)
|
||||||
assert.NoError(t, err)
|
assert.Contains(t, string(out), "Hello from shell")
|
||||||
|
|
||||||
assert.Equal(t, "Hello from bash\n", string(out))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindCmdInterpreter(t *testing.T) {
|
func TestExecutorWithDifferentShells(t *testing.T) {
|
||||||
if runtime.GOOS != "windows" {
|
for _, shell := range []string{"bash", "sh", "cmd"} {
|
||||||
t.SkipNow()
|
t.Run(shell, func(t *testing.T) {
|
||||||
|
testExecutorWithShell(t, shell)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interpreter, err := findCmdInterpreter()
|
func TestExecutorNoShellFound(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
t.Setenv("PATH", "")
|
||||||
assert.NotEmpty(t, interpreter)
|
_, err := NewCommandExecutor(".")
|
||||||
|
assert.ErrorContains(t, err, "no shell found")
|
||||||
e, err := NewCommandExecutor(".")
|
|
||||||
assert.NoError(t, err)
|
|
||||||
e.interpreter = interpreter
|
|
||||||
|
|
||||||
assert.NoError(t, err)
|
|
||||||
out, err := e.Exec(context.Background(), `echo "Hello from cmd"`)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Contains(t, string(out), "Hello from cmd")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutorCleanupsTempFiles(t *testing.T) {
|
func TestExecutorCleanupsTempFiles(t *testing.T) {
|
||||||
executor, err := NewCommandExecutor(".")
|
executor, err := NewCommandExecutor(".")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
ec, err := executor.interpreter.prepare("echo 'Hello'")
|
ec, err := executor.shell.prepare("echo 'Hello'")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
cmd, err := executor.start(context.Background(), ec)
|
cmd, err := executor.start(context.Background(), ec)
|
||||||
|
|
|
@ -1,123 +0,0 @@
|
||||||
package exec
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
osexec "os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
type interpreter interface {
|
|
||||||
prepare(string) (*execContext, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type execContext struct {
|
|
||||||
executable string
|
|
||||||
args []string
|
|
||||||
scriptFile string
|
|
||||||
}
|
|
||||||
|
|
||||||
type bashInterpreter struct {
|
|
||||||
executable string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *bashInterpreter) prepare(command string) (*execContext, error) {
|
|
||||||
filename, err := createTempScript(command, ".sh")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &execContext{
|
|
||||||
executable: b.executable,
|
|
||||||
args: []string{"-e", filename},
|
|
||||||
scriptFile: filename,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type cmdInterpreter struct {
|
|
||||||
executable string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *cmdInterpreter) prepare(command string) (*execContext, error) {
|
|
||||||
filename, err := createTempScript(command, ".cmd")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &execContext{
|
|
||||||
executable: c.executable,
|
|
||||||
args: []string{"/D", "/E:ON", "/V:OFF", "/S", "/C", fmt.Sprintf(`CALL %s`, filename)},
|
|
||||||
scriptFile: filename,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findInterpreter() (interpreter, error) {
|
|
||||||
interpreter, err := findBashInterpreter()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if interpreter != nil {
|
|
||||||
return interpreter, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
interpreter, err = findCmdInterpreter()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if interpreter != nil {
|
|
||||||
return interpreter, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, errors.New("no interpreter found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func findBashInterpreter() (interpreter, error) {
|
|
||||||
// Lookup for bash executable first (Linux, MacOS, maybe Windows)
|
|
||||||
out, err := osexec.LookPath("bash")
|
|
||||||
if err != nil && !errors.Is(err, osexec.ErrNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bash executable is not found, returning early
|
|
||||||
if out == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &bashInterpreter{executable: out}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findCmdInterpreter() (interpreter, error) {
|
|
||||||
// Lookup for CMD executable (Windows)
|
|
||||||
out, err := osexec.LookPath("cmd")
|
|
||||||
if err != nil && !errors.Is(err, osexec.ErrNotFound) {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CMD executable is not found, returning early
|
|
||||||
if out == "" {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cmdInterpreter{executable: out}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createTempScript(command string, extension string) (string, error) {
|
|
||||||
file, err := os.CreateTemp(os.TempDir(), "cli-exec*"+extension)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
_, err = io.WriteString(file, command)
|
|
||||||
if err != nil {
|
|
||||||
// Try to remove the file if we failed to write to it
|
|
||||||
os.Remove(file.Name())
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return file.Name(), nil
|
|
||||||
}
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type shell interface {
|
||||||
|
prepare(string) (*execContext, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type execContext struct {
|
||||||
|
executable string
|
||||||
|
args []string
|
||||||
|
scriptFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func findShell() (shell, error) {
|
||||||
|
for _, fn := range []func() (shell, error){
|
||||||
|
newBashShell,
|
||||||
|
newShShell,
|
||||||
|
newCmdShell,
|
||||||
|
} {
|
||||||
|
shell, err := fn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if shell != nil {
|
||||||
|
return shell, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("no shell found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTempScript(command string, extension string) (string, error) {
|
||||||
|
file, err := os.CreateTemp(os.TempDir(), "cli-exec*"+extension)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.WriteString(file, command)
|
||||||
|
if err != nil {
|
||||||
|
// Try to remove the file if we failed to write to it
|
||||||
|
os.Remove(file.Name())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return file.Name(), nil
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
osexec "os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bashShell struct {
|
||||||
|
executable string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s bashShell) prepare(command string) (*execContext, error) {
|
||||||
|
filename, err := createTempScript(command, ".sh")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &execContext{
|
||||||
|
executable: s.executable,
|
||||||
|
args: []string{"-e", filename},
|
||||||
|
scriptFile: filename,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBashShell() (shell, error) {
|
||||||
|
out, err := osexec.LookPath("bash")
|
||||||
|
if err != nil && !errors.Is(err, osexec.ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// `bash` is not found, return early.
|
||||||
|
if out == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &bashShell{executable: out}, nil
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBashFound(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
shell, err := newBashShell()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBashNotFound(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
shell, err := newBashShell()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, shell)
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
osexec "os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cmdShell struct {
|
||||||
|
executable string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s cmdShell) prepare(command string) (*execContext, error) {
|
||||||
|
filename, err := createTempScript(command, ".cmd")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &execContext{
|
||||||
|
executable: s.executable,
|
||||||
|
args: []string{"/D", "/E:ON", "/V:OFF", "/S", "/C", fmt.Sprintf(`CALL %s`, filename)},
|
||||||
|
scriptFile: filename,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCmdShell() (shell, error) {
|
||||||
|
out, err := osexec.LookPath("cmd")
|
||||||
|
if err != nil && !errors.Is(err, osexec.ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// `cmd.exe` is not found, return early.
|
||||||
|
if out == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cmdShell{executable: out}, nil
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCmdFound(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
shell, err := newCmdShell()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCmdNotFound(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
shell, err := newCmdShell()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, shell)
|
||||||
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
osexec "os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type shShell struct {
|
||||||
|
executable string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s shShell) prepare(command string) (*execContext, error) {
|
||||||
|
filename, err := createTempScript(command, ".sh")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &execContext{
|
||||||
|
executable: s.executable,
|
||||||
|
args: []string{"-e", filename},
|
||||||
|
scriptFile: filename,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newShShell() (shell, error) {
|
||||||
|
out, err := osexec.LookPath("sh")
|
||||||
|
if err != nil && !errors.Is(err, osexec.ErrNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// `sh` is not found, return early.
|
||||||
|
if out == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &shShell{executable: out}, nil
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShFound(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
shell, err := newShShell()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShNotFound(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
shell, err := newShShell()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Nil(t, shell)
|
||||||
|
}
|
Loading…
Reference in New Issue