From 3dafdcb3f9227584cb65ec01bf56e67bd75ef3a0 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Wed, 14 Oct 2026 17:27:22 -0400 Subject: [PATCH] fix: detect storage:exec TTY with isatty, not ModeCharDevice /dev/null is a character device but not a terminal. Treating it as a TTY made docker run -it fail under docker exec without -t, which broke the Dokku-in-Docker storage bats job in CI. --- plugins/storage/commands_entries.go | 15 ++++---- plugins/storage/commands_entries_test.go | 45 ++++++++++++++++++++++++ plugins/storage/go.mod | 2 +- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/plugins/storage/commands_entries.go b/plugins/storage/commands_entries.go index fd8624ccc..e35a6afcd 100644 --- a/plugins/storage/commands_entries.go +++ b/plugins/storage/commands_entries.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/dokku/dokku/plugins/common" + "github.com/mattn/go-isatty" ) // CommandCreateInput captures the flags accepted by storage:create. @@ -422,14 +423,16 @@ func CommandExec(input CommandExecInput) error { // stdinModes inspects os.Stdin to decide whether docker / the k8s exec // SDK should request an interactive session and a TTY. +// +// TTY detection uses isatty rather than ModeCharDevice: /dev/null is a +// character device but not a terminal, and treating it as a TTY makes +// docker run -it fail with "cannot attach stdin to a TTY-enabled +// container because stdin is not a terminal" (e.g. docker exec without +// -t, as in CI). func stdinModes() (interactive bool, tty bool) { + tty = isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd()) fi, err := os.Stdin.Stat() - if err != nil || fi == nil { - return false, false - } - mode := fi.Mode() - tty = mode&os.ModeCharDevice != 0 - hasStdinData := mode&os.ModeNamedPipe != 0 + hasStdinData := err == nil && fi != nil && fi.Mode()&os.ModeNamedPipe != 0 interactive = tty || hasStdinData return interactive, tty } diff --git a/plugins/storage/commands_entries_test.go b/plugins/storage/commands_entries_test.go index 41139a880..58a842970 100644 --- a/plugins/storage/commands_entries_test.go +++ b/plugins/storage/commands_entries_test.go @@ -1,6 +1,7 @@ package storage import ( + "os" "path/filepath" "testing" @@ -218,3 +219,47 @@ func TestCommandSetPersistsAndValidates(t *testing.T) { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("does not exist")) } + +// TestStdinModesDevNullIsNotTTY guards the CI / docker-exec path: stdin is +// often /dev/null, which is a character device but not a terminal. ModeCharDevice +// alone would request docker run -it and fail attach. +func TestStdinModesDevNullIsNotTTY(t *testing.T) { + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer devNull.Close() + + old := os.Stdin + os.Stdin = devNull + t.Cleanup(func() { os.Stdin = old }) + + interactive, tty := stdinModes() + if tty { + t.Fatalf("expected tty=false for /dev/null, got tty=true interactive=%v", interactive) + } + if interactive { + t.Fatalf("expected interactive=false for /dev/null, got interactive=true") + } +} + +func TestStdinModesNamedPipeIsInteractiveWithoutTTY(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + + old := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = old }) + + interactive, tty := stdinModes() + if tty { + t.Fatalf("expected tty=false for a pipe, got tty=true") + } + if !interactive { + t.Fatalf("expected interactive=true for a named pipe") + } +} diff --git a/plugins/storage/go.mod b/plugins/storage/go.mod index 1c93444bf..66410f03a 100644 --- a/plugins/storage/go.mod +++ b/plugins/storage/go.mod @@ -5,6 +5,7 @@ go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 github.com/dokku/dokku/plugins/docker-options v0.0.0-00010101000000-000000000000 + github.com/mattn/go-isatty v0.0.24 github.com/onsi/gomega v1.44.0 github.com/spf13/pflag v1.0.10 ) @@ -17,7 +18,6 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.24 // indirect github.com/melbahja/goph v1.5.2 // indirect github.com/otiai10/copy v1.14.1 // indirect github.com/otiai10/mint v1.6.3 // indirect