Repository: puppetlabs/wash
Pull request #306 (2019-05-30 18:51:56)

Provide IsInteractive test for plugins
Provide an IsInteractive function to test whether Wash is running in an
interactive mode so plugins can decide whether to prompt for input. This
prevents blocking when running a script via wash.

Signed-off-by: Michael Smith <michael.smith@puppet.com>

ekinanp: I think we should spend some time on properly handling authentication so that the MFA stuff can be generalized to external plugins. That way, users who are concerned about exposing sensitive stuff through Wash can still use it via the external plugin interface knowing full well that Wash has an API for handling authentication.

Patch:
diff --git a/cmd/rootMain.go b/cmd/rootMain.go
index 8487cdf..399d141 100644
--- a/cmd/rootMain.go
+++ b/cmd/rootMain.go
@@ -8,9 +8,9 @@ import (
 	"path/filepath"
 	"strings"
 
-	"github.com/mattn/go-isatty"
 	"github.com/puppetlabs/wash/cmd/internal/server"
 	cmdutil "github.com/puppetlabs/wash/cmd/util"
+	"github.com/puppetlabs/wash/plugin"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -142,6 +142,14 @@ func rootMain(cmd *cobra.Command, args []string) exitCode {
 	}
 	defer os.RemoveAll(rundir)
 
+	var execfile string
+	if len(args) > 0 {
+		execfile = args[0]
+	}
+
+	// Set plugin interactivity to false if execfile or rootCommandFlag were specified.
+	plugin.InitInteractive(execfile == "" && rootCommandFlag == "")
+
 	// TODO: instead of running a server in-process, can we start one in a separate process that can
 	//       be shared between multiple invocations of `wash`?
 	serverOpts, err := serverOptsFor(cmd)
@@ -156,12 +164,7 @@ func rootMain(cmd *cobra.Command, args []string) exitCode {
 		return exitCode{1}
 	}
 
-	var execfile string
-	if len(args) > 0 {
-		execfile = args[0]
-	}
-
-	if isInteractive(execfile) {
+	if plugin.IsInteractive() {
 		fmt.Println(`Welcome to Wash!
   Wash includes several built-in commands: wexec, find, list, meta, tail.
   See commands run with wash via 'whistory', and logs with 'whistory <id>'.
@@ -171,15 +174,8 @@ Try 'help'`)
 	exit := runShell(rundir, mountpath, socketpath, execfile)
 
 	srv.Stop()
-	if isInteractive(execfile) {
+	if plugin.IsInteractive() {
 		fmt.Println("Goodbye!")
 	}
 	return exit
 }
-
-// Returns true if both input and output are interactive.
-func isInteractive(execfile string) bool {
-	return execfile == "" && rootCommandFlag == "" &&
-		(isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())) &&
-		(isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()))
-}
diff --git a/plugin/aws/profile.go b/plugin/aws/profile.go
index 06194c4..fbf9422 100644
--- a/plugin/aws/profile.go
+++ b/plugin/aws/profile.go
@@ -3,12 +3,10 @@ package aws
 import (
 	"context"
 	"fmt"
-	"os"
 	"time"
 
 	"github.com/aws/aws-sdk-go/aws/credentials"
 	"github.com/aws/aws-sdk-go/aws/session"
-	"github.com/mattn/go-isatty"
 	"github.com/puppetlabs/wash/activity"
 	"github.com/puppetlabs/wash/plugin"
 )
@@ -28,13 +26,7 @@ func newProfile(ctx context.Context, name string) (*profile, error) {
 
 	// profile-specific stdin prompt
 	tokenProvider := func() (string, error) {
-		if !isatty.IsTerminal(os.Stdin.Fd()) && !isatty.IsCygwinTerminal(os.Stdin.Fd()) {
-			return "", fmt.Errorf("Not an interactive session")
-		}
-		fmt.Fprintf(os.Stderr, "Assume Role MFA token code for %v: ", name)
-		var v string
-		_, err := fmt.Scanln(&v)
-		return v, err
+		return plugin.Prompt(fmt.Sprintf("Assume ROLE MFA token code for %v", name))
 	}
 
 	// Create the session. SharedConfigEnable tells AWS to load the profile
diff --git a/plugin/helpers.go b/plugin/helpers.go
index 1c41fe7..3a19d79 100644
--- a/plugin/helpers.go
+++ b/plugin/helpers.go
@@ -77,4 +77,4 @@ func TrackTime(start time.Time, name string) {
 // Attributes returns the entry's attribtues
 func Attributes(e Entry) EntryAttributes {
 	return e.attributes()
-}
\ No newline at end of file
+}
diff --git a/plugin/interactive.go b/plugin/interactive.go
new file mode 100644
index 0000000..28761a2
--- /dev/null
+++ b/plugin/interactive.go
@@ -0,0 +1,34 @@
+package plugin
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/mattn/go-isatty"
+)
+
+var isInteractive bool = (isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())) &&
+	(isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()))
+
+// InitInteractive is used by Wash commands to set option-specific overrides. Only sets
+// interactivity to true if it already was and 'init' is also true.
+func InitInteractive(init bool) {
+	isInteractive = init && isInteractive
+}
+
+// IsInteractive returns true if Wash is running as an interactive session. If false, please don't
+// prompt for input on stdin.
+func IsInteractive() bool {
+	return isInteractive
+}
+
+// Prompt prints the supplied message, then waits for input on stdin.
+func Prompt(msg string) (string, error) {
+	if !IsInteractive() {
+		return "", fmt.Errorf("Not an interactive session")
+	}
+	fmt.Fprintf(os.Stderr, "%s: ", msg)
+	var v string
+	_, err := fmt.Scanln(&v)
+	return v, err
+}


Test patch:
diff --git a/plugin/interactive_test.go b/plugin/interactive_test.go
new file mode 100644
index 0000000..0c5171c
--- /dev/null
+++ b/plugin/interactive_test.go
@@ -0,0 +1,23 @@
+package plugin
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestInteractive(t *testing.T) {
+	if !IsInteractive() {
+		// If tests are not run interactively we can only test the false case, so override to true.
+		isInteractive = true
+	}
+
+	InitInteractive(true)
+	assert.True(t, IsInteractive())
+
+	InitInteractive(false)
+	assert.False(t, IsInteractive())
+
+	InitInteractive(true)
+	assert.False(t, IsInteractive())
+}