diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc4ee7e0..e39f0e9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,10 +13,11 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-latest, macos-11.0, ubuntu-latest] + os: [macos-latest, ubuntu-latest] go-version: [1.17.6] + shell: [/bin/bash, /bin/zsh] - name: ${{ matrix.os }} / go-${{ matrix.go-version }} + name: ${{ matrix.os }} / go-${{ matrix.go-version }} / ${{ matrix.shell }} steps: - uses: actions/setup-go@v1 with: @@ -30,6 +31,9 @@ jobs: - uses: actions/checkout@v2 + - if: contains(matrix.os, 'ubuntu') + run: sudo apt install zsh + - if: contains(matrix.os, 'macos') run: | sw_vers @@ -40,6 +44,8 @@ jobs: - run: go mod download - run: go test -v -race -coverprofile=coverage.out -covermode=atomic -timeout=300s ./... + env: + SHELL: ${{ matrix.shell }} devel-release: runs-on: macos-latest diff --git a/dev/app.go b/dev/app.go index 955ddc68..2c799ee3 100644 --- a/dev/app.go +++ b/dev/app.go @@ -24,8 +24,6 @@ import ( "gopkg.in/tomb.v2" ) -const DefaultThreads = 5 - var ErrUnexpectedExit = errors.New("unexpected exit") type App struct { @@ -49,10 +47,6 @@ type App struct { pool *AppPool lastUse time.Time - lock sync.Mutex - - booting bool - readyChan chan struct{} } @@ -133,7 +127,7 @@ func (a *App) watch() error { reason := "detected interval shutdown" select { - case err = <-c: + case <-c: reason = "stdout/stderr closed" err = fmt.Errorf("%s:\n\t%s", ErrUnexpectedExit, a.lastLogLine) case <-a.t.Dying(): @@ -237,64 +231,24 @@ func (a *App) Log() string { return buf.String() } -const executionShell = `exec bash -c ' -cd %s - -if test -e ~/.powconfig && [ "$PUMADEV_SOURCE_POWCONFIG" != "0" ]; then - source ~/.powconfig -fi - -if test -e .env && [ "$PUMADEV_SOURCE_ENV" != "0" ]; then - source .env -fi - -if test -e .powrc && [ "$PUMADEV_SOURCE_POWRC" != "0" ]; then - source .powrc -fi - -if test -e .powenv && [ "$PUMADEV_SOURCE_POWENV" != "0" ]; then - source .powenv -fi - -if test -e .pumaenv && [ "$PUMADEV_SOURCE_PUMAENV" != "0" ]; then - source .pumaenv -fi - -if test -e Gemfile && bundle exec puma -V &>/dev/null; then - exec bundle exec puma -C $CONFIG --tag puma-dev:%s -w $WORKERS -t 0:$THREADS -b unix:%s -fi - -exec puma -C $CONFIG --tag puma-dev:%s -w $WORKERS -t 0:$THREADS -b unix:%s' -` - func (pool *AppPool) LaunchApp(name, dir string) (*App, error) { - tmpDir := filepath.Join(dir, "tmp") - err := os.MkdirAll(tmpDir, 0755) - if err != nil { + appDir, dirErr := filepath.EvalSymlinks(dir) + if dirErr != nil { + return nil, dirErr + } + + tmpDir := filepath.Join(appDir, "tmp") + if err := os.MkdirAll(tmpDir, 0755); err != nil { return nil, err } socket := filepath.Join(tmpDir, fmt.Sprintf("puma-dev-%d.sock", os.Getpid())) - shell := os.Getenv("SHELL") - - if shell == "" { - fmt.Printf("! SHELL env var not set, using /bin/bash by default") - shell = "/bin/bash" + cmd, err := BuildPumaCommand(name, socket, appDir) + if err != nil { + return nil, err } - cmd := exec.Command(shell, "-l", "-i", "-c", - fmt.Sprintf(executionShell, dir, name, socket, name, socket)) - - cmd.Dir = dir - - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, - fmt.Sprintf("THREADS=%d", DefaultThreads), - "WORKERS=0", - "CONFIG=-", - ) - stdout, err := cmd.StdoutPipe() if err != nil { return nil, err @@ -314,7 +268,7 @@ func (pool *AppPool) LaunchApp(name, dir string) (*App, error) { Command: cmd, Events: pool.Events, stdout: stdout, - dir: dir, + dir: appDir, pool: pool, readyChan: make(chan struct{}), lastUse: time.Now(), @@ -322,7 +276,7 @@ func (pool *AppPool) LaunchApp(name, dir string) (*App, error) { app.eventAdd("booting_app", "socket", socket) - stat, err := os.Stat(filepath.Join(dir, "public")) + stat, err := os.Stat(filepath.Join(appDir, "public")) if err == nil { app.Public = stat.IsDir() } @@ -639,3 +593,81 @@ func (a *AppPool) Purge() { a.Events.Add("apps_purged") } + +const pumaShellScriptTemplate = `exec %s -c ' +cd %s + +if test -e ~/.powconfig && [ "$PUMADEV_SOURCE_POWCONFIG" != "0" ]; then + source ~/.powconfig +fi + +if test -e ~/.pumaenv && [ "$PUMADEV_SOURCE_HOME_PUMAENV" != "0" ]; then + source ~/.pumaenv +fi + +if test -e .env && [ "$PUMADEV_SOURCE_ENV" != "0" ]; then + source .env +fi + +if test -e .powrc && [ "$PUMADEV_SOURCE_POWRC" != "0" ]; then + source .powrc +fi + +if test -e .powenv && [ "$PUMADEV_SOURCE_POWENV" != "0" ]; then + source .powenv +fi + +if test -e .pumaenv && [ "$PUMADEV_SOURCE_PUMAENV" != "0" ]; then + source .pumaenv +fi + +if test -e Gemfile && bundle exec puma -V &>/dev/null; then + exec bundle exec puma %s +fi + +exec puma %s +'` // <-- don't forget this closing quote + +func BuildPumaCommand(appName string, socketPath string, appDir string) (*exec.Cmd, error) { + + osMapEnv := GetMapEnviron() + + // This environment will be fed into the puma exec shell. + // However, we _also_ source each supported env file inside the shell to allow for + // custom environment grooming. + var mapEnv map[string]string + mapEnv, err := LoadEnv(osMapEnv, appDir) + if err != nil { + fmt.Printf("! Falling back to default environment. %v\n", err.Error()) + mapEnv = osMapEnv + } + + pumaArgs := fmt.Sprintf("--tag puma-dev:%s -b unix:%s", appName, socketPath) + + if workers, exist := mapEnv["WORKERS"]; exist { + pumaArgs = fmt.Sprintf("-w%s %s", workers, pumaArgs) + } + + if threads, exist := mapEnv["THREADS"]; exist { + pumaArgs = fmt.Sprintf("-t0:%s %s", threads, pumaArgs) + } + + if config, exist := mapEnv["CONFIG"]; exist { + pumaArgs = fmt.Sprintf("-C%s %s", config, pumaArgs) + } + + // use SHELL from OS env or app env + execShell := mapEnv["SHELL"] + if execShell == "" { + fmt.Printf("! SHELL env var not set, using /bin/bash by default\n") + execShell = "/bin/bash" + } + + script := fmt.Sprintf(pumaShellScriptTemplate, execShell, appDir, pumaArgs, pumaArgs) + + cmd := exec.Command(execShell, "-l", "-i", "-c", script) + cmd.Dir = appDir + cmd.Env = ToCmdEnv(mapEnv) + + return cmd, nil +} diff --git a/dev/app_test.go b/dev/app_test.go new file mode 100644 index 00000000..6acd774a --- /dev/null +++ b/dev/app_test.go @@ -0,0 +1,27 @@ +package dev + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBuildPumaCommand_withEnv(t *testing.T) { + t.Setenv("CONFIG", "-") + t.Setenv("WORKERS", "4") + t.Setenv("THREADS", "5") + + cmd, _ := BuildPumaCommand("foo", "/tmp/tmp/foo.sock", "/tmp") + + assert.Regexp(t, " -w4 ", cmd.Args[4]) + assert.Regexp(t, " -t0:5 ", cmd.Args[4]) + assert.Regexp(t, " -C- ", cmd.Args[4]) +} + +func TestBuildPumaCommand_withoutEnv(t *testing.T) { + cmd, _ := BuildPumaCommand("foo", "/tmp/tmp/foo.sock", "/tmp") + + assert.NotRegexp(t, " -w", cmd.Args[4]) + assert.NotRegexp(t, " -t", cmd.Args[4]) + assert.NotRegexp(t, " -C", cmd.Args[4]) +} diff --git a/dev/env.go b/dev/env.go new file mode 100644 index 00000000..62b73219 --- /dev/null +++ b/dev/env.go @@ -0,0 +1,74 @@ +package dev + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/joho/godotenv" + "github.com/puma/puma-dev/homedir" +) + +// `.pumaenv` has highest priority, `~/.powconfig` has lowest. +// Be sure to keep these in sync with the pumaShellScriptTemplate in app.go +var AppEnvPaths = []string{".env", ".powrc", ".powenv", ".pumaenv"} +var HomeEnvPaths = []string{"~/.powconfig", "~/.pumaenv"} + +func LoadEnv(baseEnv map[string]string, dir string) (envMap map[string]string, err error) { + // build a list of all the supported env files that exist on the filesystem to load + var envPaths []string + + for _, path := range AppEnvPaths { + fullPath := filepath.Join(dir, path) + if _, err := os.Stat(fullPath); err == nil { + envPaths = append(envPaths, fullPath) + } + } + + for _, path := range HomeEnvPaths { + if fullPath, err := homedir.Expand(path); err == nil { + if _, err := os.Stat(fullPath); err == nil { + envPaths = append(envPaths, fullPath) + } + } + } + + envMap = baseEnv + + // if we have any optional env paths to source, source them + if len(envPaths) > 0 { + // load the env into a map + appEnv, err := godotenv.Read(envPaths...) + if err != nil { + // if any path fails to load, bail out. + return nil, fmt.Errorf("%v in %v", err, envPaths) + } + + // merge contents from appEnv into envMap + // this way dotenv values will supersede os env values + for k, v := range appEnv { + envMap[k] = v + } + } + + return +} + +func ToCmdEnv(envMap map[string]string) (env []string) { + for k, v := range envMap { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + return +} + +func GetMapEnviron() (envMap map[string]string) { + envMap = make(map[string]string) + + for _, keyval := range os.Environ() { + pair := strings.Split(keyval, "=") + envMap[pair[0]] = pair[1] + } + + return +} diff --git a/dev/env_test.go b/dev/env_test.go new file mode 100644 index 00000000..fbf94335 --- /dev/null +++ b/dev/env_test.go @@ -0,0 +1,75 @@ +package dev + +import ( + "io/fs" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/puma/puma-dev/homedir" + "github.com/stretchr/testify/assert" +) + +func TestLoadEnv_shellScript(t *testing.T) { + base := make(map[string]string) + appDir, _ := os.MkdirTemp("/tmp", "TestLoadEnv_fileEnvPriority") + + // if an env file contains more than just variables, we'll bail out + // and fall back to default behavior + ioutil.WriteFile(filepath.Join(appDir, ".powrc"), []byte(`function puma-dev-test () { echo 'hi puma'; }`), fs.FileMode(0600)) + + _, err := LoadEnv(base, appDir) + + assert.Error(t, err) +} + +func TestLoadEnv_fileEnvPriority(t *testing.T) { + base := make(map[string]string) + appDir, _ := os.MkdirTemp("/tmp", "TestLoadEnv_fileEnvPriority") + + ioutil.WriteFile(filepath.Join(appDir, ".pumaenv"), []byte("FILE=.pumaenv"), fs.FileMode(0600)) + ioutil.WriteFile(filepath.Join(appDir, ".powenv"), []byte("FILE=.powenv"), fs.FileMode(0600)) + ioutil.WriteFile(filepath.Join(appDir, ".powrc"), []byte("FILE=.powrc"), fs.FileMode(0600)) + ioutil.WriteFile(filepath.Join(appDir, ".env"), []byte("FILE=.env"), fs.FileMode(0600)) + + res, _ := LoadEnv(base, appDir) + + assert.Equal(t, res["FILE"], ".pumaenv") +} + +func TestLoadEnv_homedirFileEnv(t *testing.T) { + if os.Getenv("CI") != "true" { + t.Skip("Skipping LoadEnv homedir tests outside CI") + } + + base := make(map[string]string) + appDir, _ := os.MkdirTemp("/tmp", "TestLoadEnv_homedirFileEnv") + + powconfig := homedir.MustExpand("~/.powconfig") + pumaenv := homedir.MustExpand("~/.pumaenv") + + ioutil.WriteFile(powconfig, []byte("FILE=~/.powconfig"), fs.FileMode(0600)) + ioutil.WriteFile(pumaenv, []byte("FILE=~/.pumaenv"), fs.FileMode(0600)) + + defer func() { + os.Remove(powconfig) + os.Remove(pumaenv) + }() + + res, _ := LoadEnv(base, appDir) + + assert.Equal(t, res["FILE"], "~/.pumaenv") + +} + +func TestLoadEnv(t *testing.T) { + t.Setenv("FILE", "environment") + + base := GetMapEnviron() + appDir, _ := os.MkdirTemp("/tmp", "TestLoadEnv") + + res, _ := LoadEnv(base, appDir) + + assert.Equal(t, res["FILE"], "environment") +} diff --git a/dev/ssl_darwin.go b/dev/ssl_darwin.go index 129ff77a..ba3480c3 100644 --- a/dev/ssl_darwin.go +++ b/dev/ssl_darwin.go @@ -36,7 +36,7 @@ func TrustCert(cert string) error { func DeleteAllPumaDevCAFromDefaultKeychain() error { deleteAllBashCommand := ` - for sha in $(security find-certificate -a -c "Puma-dev CA" -Z | awk '/SHA-1/ {print $3}'); do + for sha in $(security find-certificate -a -c "Puma-dev CA" -Z | awk '/SHA-1/ {print $3}'); do security delete-certificate -t -Z $sha || security delete-certificate -Z $sha done ` @@ -65,5 +65,5 @@ func loginKeyChain() (string, error) { return "", fmt.Errorf("could not find login keychain. security login-keychain had %s, %s", err.Error(), stderr.Bytes()) } - return string(stdout.Bytes()), nil + return stdout.String(), nil } diff --git a/go.mod b/go.mod index 137be06a..ce9cb95d 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,15 @@ go 1.13 require ( github.com/avast/retry-go v2.5.0+incompatible github.com/bmizerany/pat v0.0.0-20170815010413-6226ea591a40 + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsevents v0.1.1 github.com/fsnotify/fsnotify v1.4.9 github.com/hashicorp/golang-lru v0.5.4 + github.com/joho/godotenv v1.4.0 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/miekg/dns v1.1.29 - github.com/stretchr/objx v0.2.0 // indirect github.com/stretchr/testify v1.5.1 github.com/vektra/errors v0.0.0-20140903201135-c64d83aba85a - github.com/vektra/neko v0.0.0-20141017182438-843f5ecf6932 golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 // indirect golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect golang.org/x/sys v0.0.0-20200331124033-c3d80250170d // indirect diff --git a/go.sum b/go.sum index 3e8c7a7f..48ac0dd3 100644 --- a/go.sum +++ b/go.sum @@ -11,6 +11,8 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/miekg/dns v1.1.29 h1:xHBEhR+t5RzcFJjBLJlax2daXOrTYtr9z4WdKEfWFzg= @@ -18,15 +20,10 @@ github.com/miekg/dns v1.1.29/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= -github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/vektra/errors v0.0.0-20140903201135-c64d83aba85a h1:lUVfiMMY/te9icPKBqOKkBIMZNxSpM90dxokDeCcfBg= github.com/vektra/errors v0.0.0-20140903201135-c64d83aba85a/go.mod h1:KUxJS71XlMs+ztT+RzsLRoWUQRUpECo/+Rb0EBk8/Wc= -github.com/vektra/neko v0.0.0-20141017182438-843f5ecf6932 h1:SslNPzxMsvmq9d29joAV87uY34VPgN0W9CDIcztcipg= -github.com/vektra/neko v0.0.0-20141017182438-843f5ecf6932/go.mod h1:7tfPLehrsToaevw9Vi9iL6FOslcBJ/uqYQc8y3YIbdI= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8 h1:fpnn/HnJONpIu6hkXi1u/7rR0NzilgWr4T0JmWkEitk= diff --git a/linebuffer/linebuffer_test.go b/linebuffer/linebuffer_test.go index 47bf953e..99a4a2cf 100644 --- a/linebuffer/linebuffer_test.go +++ b/linebuffer/linebuffer_test.go @@ -5,14 +5,10 @@ import ( "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/vektra/neko" ) func TestLineBuffer(t *testing.T) { - n := neko.Start(t) - - n.It("appends lines to the buffer", func() { + t.Run("appends lines to the buffer", func(t *testing.T) { var lb LineBuffer lb.Append("hello") @@ -20,12 +16,12 @@ func TestLineBuffer(t *testing.T) { var buf bytes.Buffer n, err := lb.WriteTo(&buf) - require.NoError(t, err) + assert.NoError(t, err) assert.Equal(t, int64(5), n) }) - n.It("wraps around automatically", func() { + t.Run("wraps around automatically", func(t *testing.T) { var lb LineBuffer lb.Size = 3 @@ -49,7 +45,7 @@ func TestLineBuffer(t *testing.T) { assert.Equal(t, "hello4", lines[2]) }) - n.It("wraps around automatically multiple times", func() { + t.Run("wraps around automatically multiple times", func(t *testing.T) { var lb LineBuffer lb.Size = 3 @@ -76,5 +72,4 @@ func TestLineBuffer(t *testing.T) { assert.Equal(t, "hello7", lines[2]) }) - n.Meow() }