From 69ba34845b11c3b309863c7bc872fba21cc07d41 Mon Sep 17 00:00:00 2001 From: Adam Babik Date: Mon, 2 Dec 2024 19:52:56 +0100 Subject: [PATCH] Implement "runme beta session" (#683) It implements a new command that allows starting a session and collecting all exported environment variables throughout its lifetime. ## Testing As a prerequisite, add the following to your shell's startup script (`.bashrc` or `.zshrc`): ``` # runme source <(runme beta session setup) ``` Then, you can start a new session in the terminal: ``` runme beta session ``` The session is a regular shell of your choice, inferred from `$SHELL`. You can use it like a normal shell, but any exported environment variables will be returned upon exiting the session via `exit`. Currently, the collected variables are printed out, but in the future, they will be routed to a proper store. --------- Co-authored-by: Sebastian (Tiedtke) Huckleberry --- internal/cmd/beta/beta_cmd.go | 1 + internal/cmd/beta/session_cmd.go | 197 ++++++++++++++++++ internal/command/command_inline_shell.go | 20 +- internal/command/command_terminal.go | 1 + internal/command/command_test.go | 8 +- internal/command/command_unix_test.go | 6 +- internal/command/env_collector.go | 12 +- internal/command/env_collector_factory.go | 29 ++- internal/command/env_collector_fifo_unix.go | 21 +- .../command/env_collector_fifo_unix_test.go | 29 ++- internal/command/env_collector_file.go | 21 +- internal/command/env_collector_file_test.go | 27 ++- internal/command/env_shell.go | 83 ++++++-- internal/command/env_shell_test.go | 52 ++++- internal/command/factory.go | 19 +- internal/command/terminal_session.go | 8 + internal/runnerv2client/client_test.go | 2 +- internal/runnerv2service/service_test.go | 2 +- .../gen/proto/go/runme/runner/v2/config.pb.go | 19 +- .../proto/gql/runme/runner/v2/runner.graphql | 1 + .../proto/ts/runme/runner/v2/config_pb.d.ts | 6 +- .../gen/proto/ts/runme/runner/v2/config_pb.js | 4 + pkg/api/proto/runme/runner/v2/config.proto | 12 ++ 23 files changed, 484 insertions(+), 96 deletions(-) create mode 100644 internal/cmd/beta/session_cmd.go create mode 100644 internal/command/terminal_session.go diff --git a/internal/cmd/beta/beta_cmd.go b/internal/cmd/beta/beta_cmd.go index 65620a890..919121763 100644 --- a/internal/cmd/beta/beta_cmd.go +++ b/internal/cmd/beta/beta_cmd.go @@ -96,6 +96,7 @@ All commands use the runme.yaml configuration file.`, cmd.AddCommand(listCmd(cFlags)) cmd.AddCommand(printCmd(cFlags)) + cmd.AddCommand(sessionCmd(cFlags)) cmd.AddCommand(server.Cmd()) cmd.AddCommand(runCmd(cFlags)) cmd.AddCommand(envCmd(cFlags)) diff --git a/internal/cmd/beta/session_cmd.go b/internal/cmd/beta/session_cmd.go new file mode 100644 index 000000000..e1278de63 --- /dev/null +++ b/internal/cmd/beta/session_cmd.go @@ -0,0 +1,197 @@ +package beta + +import ( + "context" + "io" + "os" + "os/exec" + "strconv" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + "go.uber.org/multierr" + "go.uber.org/zap" + + "github.com/stateful/runme/v3/internal/command" + "github.com/stateful/runme/v3/internal/config/autoconfig" + runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2" +) + +func sessionCmd(*commonFlags) *cobra.Command { + cmd := cobra.Command{ + Use: "session", + Short: "Start shell within a session.", + Long: `Start shell within a session. + +All exported variables during the session will be available to the subsequent commands. +`, + RunE: func(cmd *cobra.Command, args []string) error { + return autoconfig.InvokeForCommand( + func( + cmdFactory command.Factory, + logger *zap.Logger, + ) error { + defer logger.Sync() + + envs, err := executeDefaultShellProgram( + cmd.Context(), + cmdFactory, + cmd.InOrStdin(), + cmd.OutOrStdout(), + cmd.ErrOrStderr(), + nil, + ) + if err != nil { + return err + } + + // TODO(adamb): currently, the collected env are printed out, + // but they could be put in a session. + if _, err := cmd.ErrOrStderr().Write([]byte("Collected env during the session:\n")); err != nil { + return errors.WithStack(err) + } + + for _, env := range envs { + _, err := cmd.OutOrStdout().Write([]byte(env + "\n")) + if err != nil { + return errors.WithStack(err) + } + } + + return nil + }, + ) + }, + } + + cmd.AddCommand(sessionSetupCmd()) + + return &cmd +} + +func executeDefaultShellProgram( + ctx context.Context, + commandFactory command.Factory, + stdin io.Reader, + stdout io.Writer, + stderr io.Writer, + additionalEnv []string, +) ([]string, error) { + envCollector, err := command.NewEnvCollectorFactory().Build() + if err != nil { + return nil, errors.WithStack(err) + } + + cfg := &command.ProgramConfig{ + ProgramName: defaultShell(), + Mode: runnerv2.CommandMode_COMMAND_MODE_CLI, + Env: append( + []string{command.CreateEnv(command.EnvNameTerminalSessionEnabled, "true")}, + append(envCollector.ExtraEnv(), additionalEnv...)..., + ), + } + options := command.CommandOptions{ + NoShell: true, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + } + program, err := commandFactory.Build(cfg, options) + if err != nil { + return nil, err + } + + err = program.Start(ctx) + if err != nil { + return nil, err + } + + err = program.Wait(ctx) + if err != nil { + return nil, err + } + + changed, _, err := envCollector.Diff() + return changed, err +} + +func defaultShell() string { + shell := os.Getenv("SHELL") + if shell == "" { + shell, _ = exec.LookPath("bash") + } + if shell == "" { + shell = "/bin/sh" + } + return shell +} + +func sessionSetupCmd() *cobra.Command { + var debug bool + + cmd := cobra.Command{ + Use: "setup", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + return autoconfig.InvokeForCommand( + func( + cmdFactory command.Factory, + logger *zap.Logger, + ) error { + defer logger.Sync() + + out := cmd.OutOrStdout() + + if err := requireEnvs( + command.EnvNameTerminalSessionEnabled, + command.EnvNameTerminalSessionPrePath, + command.EnvNameTerminalSessionPostPath, + ); err != nil { + logger.Info("session setup is skipped because the environment variable is not set", zap.Error(err)) + return writeNoopShellCommand(out) + } + + sessionSetupEnabled := os.Getenv(command.EnvNameTerminalSessionEnabled) + if val, err := strconv.ParseBool(sessionSetupEnabled); err != nil || !val { + logger.Debug("session setup is skipped", zap.Error(err), zap.Bool("value", val)) + return writeNoopShellCommand(out) + } + + envSetter := command.NewScriptEnvSetter( + os.Getenv(command.EnvNameTerminalSessionPrePath), + os.Getenv(command.EnvNameTerminalSessionPostPath), + debug, + ) + if err := envSetter.SetOnShell(out); err != nil { + return err + } + + if _, err := cmd.ErrOrStderr().Write([]byte("Runme session active. When you're done, execute \"exit\".\n")); err != nil { + return errors.WithStack(err) + } + + return nil + }, + ) + }, + } + + cmd.Flags().BoolVar(&debug, "debug", false, "Enable debug mode.") + + return &cmd +} + +func requireEnvs(names ...string) error { + var err error + for _, name := range names { + if os.Getenv(name) == "" { + err = multierr.Append(err, errors.Errorf("environment variable %q is required", name)) + } + } + return err +} + +func writeNoopShellCommand(w io.Writer) error { + _, err := w.Write([]byte(":")) + return errors.WithStack(err) +} diff --git a/internal/command/command_inline_shell.go b/internal/command/command_inline_shell.go index 2d827c739..cdd8eb41c 100644 --- a/internal/command/command_inline_shell.go +++ b/internal/command/command_inline_shell.go @@ -33,11 +33,13 @@ func (c *inlineShellCommand) Start(ctx context.Context) error { if err != nil { return err } - c.logger.Debug("inline shell script", zap.String("script", script)) cfg := c.ProgramConfig() - cfg.Arguments = append(cfg.Arguments, "-c", script) + + if script != "" { + cfg.Arguments = append(cfg.Arguments, "-c", script) + } if c.envCollector != nil { cfg.Env = append(cfg.Env, c.envCollector.ExtraEnv()...) @@ -50,9 +52,19 @@ func (c *inlineShellCommand) Wait(ctx context.Context) error { err := c.internalCommand.Wait(ctx) if c.envCollector != nil { - c.logger.Info("collecting the environment after the script execution") + c.logger.Info( + "collecting the environment after the script execution", + zap.Int("count", len(c.session.GetAllEnv())), // TODO(adamb): change to session.Size() + ) + cErr := c.collectEnv(ctx) - c.logger.Info("collected the environment after the script execution", zap.Error(cErr)) + + c.logger.Info( + "collected the environment after the script execution", + zap.Int("count", len(c.session.GetAllEnv())), // TODO(adamb): change to session.Size() + zap.Error(cErr), + ) + if cErr != nil && err == nil { err = cErr } diff --git a/internal/command/command_terminal.go b/internal/command/command_terminal.go index e04d75a45..4b65d58be 100644 --- a/internal/command/command_terminal.go +++ b/internal/command/command_terminal.go @@ -75,6 +75,7 @@ func (c *terminalCommand) Wait(ctx context.Context) (err error) { err = cErr } } + return err } diff --git a/internal/command/command_test.go b/internal/command/command_test.go index adc4f943a..78106891c 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -13,13 +13,7 @@ import ( ) func init() { - // Switch from "runme env" to "env -0" for the tests. - // This is because the "runme" program is not available - // in the test environment. - // - // TODO(adamb): this can be changed. runme must be built - // in the test environment and put into the PATH. - SetEnvDumpCommand("env -0") + SetEnvDumpCommandForTesting() } func testExecuteCommand( diff --git a/internal/command/command_unix_test.go b/internal/command/command_unix_test.go index 5e289f0d2..4040265db 100644 --- a/internal/command/command_unix_test.go +++ b/internal/command/command_unix_test.go @@ -12,6 +12,7 @@ import ( "time" "unicode" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" @@ -341,6 +342,7 @@ func TestCommand_SetWinsize(t *testing.T) { }, Interactive: true, Mode: runnerv2.CommandMode_COMMAND_MODE_INLINE, + Env: []string{"TERM=xterm"}, }, CommandOptions{Stdout: stdout}, ) @@ -351,8 +353,8 @@ func TestCommand_SetWinsize(t *testing.T) { err = SetWinsize(cmd, &Winsize{Rows: 45, Cols: 56, X: 0, Y: 0}) require.NoError(t, err) err = cmd.Wait(context.Background()) - require.NoError(t, err) - require.Equal(t, "56\r\n45\r\n", stdout.String()) + assert.NoError(t, err) + assert.Equal(t, "56\r\n45\r\n", stdout.String()) }) t.Run("Terminal", func(t *testing.T) { diff --git a/internal/command/env_collector.go b/internal/command/env_collector.go index cb618c125..c845b8faa 100644 --- a/internal/command/env_collector.go +++ b/internal/command/env_collector.go @@ -29,11 +29,15 @@ var envDumpCommand = func() string { return strings.Join([]string{path, "env", "dump", "--insecure"}, " ") }() -func SetEnvDumpCommand(cmd string) { - envDumpCommand = cmd +// SetEnvDumpCommandForTesting overrides the default command that dumps the environment variables. +// It is and should be used only for testing purposes. +// TODO(adamb): this can be made obsolete. runme must be built +// in the test environment and put into the PATH. +func SetEnvDumpCommandForTesting() { + envDumpCommand = "env -0" // When overriding [envDumpCommand], we disable the encryption. - // There is no way to test the encryption if the dump command - // is not controlled. + // There is no reliable way at the moment to have encryption and + // not control the dump command. envCollectorEnableEncryption = false } diff --git a/internal/command/env_collector_factory.go b/internal/command/env_collector_factory.go index 0abf6efda..7a9100607 100644 --- a/internal/command/env_collector_factory.go +++ b/internal/command/env_collector_factory.go @@ -7,22 +7,29 @@ import ( "github.com/pkg/errors" ) -type envCollectorFactoryOptions struct { +type EnvCollectorFactory struct { encryptionEnabled bool useFifo bool } -type envCollectorFactory struct { - opts envCollectorFactoryOptions +func NewEnvCollectorFactory() *EnvCollectorFactory { + return &EnvCollectorFactory{ + encryptionEnabled: envCollectorEnableEncryption, + useFifo: envCollectorUseFifo, + } } -func newEnvCollectorFactory(opts envCollectorFactoryOptions) *envCollectorFactory { - return &envCollectorFactory{ - opts: opts, - } +func (f *EnvCollectorFactory) WithEnryption(value bool) *EnvCollectorFactory { + f.encryptionEnabled = value + return f +} + +func (f *EnvCollectorFactory) UseFifo(value bool) *EnvCollectorFactory { + f.useFifo = value + return f } -func (f *envCollectorFactory) Build() (envCollector, error) { +func (f *EnvCollectorFactory) Build() (envCollector, error) { scanner := scanEnv var ( @@ -30,7 +37,7 @@ func (f *envCollectorFactory) Build() (envCollector, error) { encNonce []byte ) - if f.opts.encryptionEnabled { + if f.encryptionEnabled { var err error encKey, encNonce, err = f.generateEncryptionKeyAndNonce() @@ -48,14 +55,14 @@ func (f *envCollectorFactory) Build() (envCollector, error) { } } - if f.opts.useFifo && runtimestd.GOOS != "windows" { + if f.useFifo && runtimestd.GOOS != "windows" { return newEnvCollectorFifo(scanner, encKey, encNonce) } return newEnvCollectorFile(scanner, encKey, encNonce) } -func (f *envCollectorFactory) generateEncryptionKeyAndNonce() ([]byte, []byte, error) { +func (f *EnvCollectorFactory) generateEncryptionKeyAndNonce() ([]byte, []byte, error) { key, err := createEnvEncryptionKey() if err != nil { return nil, nil, errors.WithMessage(err, "failed to create the encryption key") diff --git a/internal/command/env_collector_fifo_unix.go b/internal/command/env_collector_fifo_unix.go index b47ac50a0..aa40f1801 100644 --- a/internal/command/env_collector_fifo_unix.go +++ b/internal/command/env_collector_fifo_unix.go @@ -28,6 +28,8 @@ type envCollectorFifo struct { temp *tempDirectory } +var _ envCollector = (*envCollectorFifo)(nil) + func newEnvCollectorFifo( scanner envScanner, encKey, @@ -87,7 +89,7 @@ func (c *envCollectorFifo) init() error { } func (c *envCollectorFifo) Diff() (changed []string, deleted []string, _ error) { - defer c.temp.Cleanup() + defer func() { _ = c.temp.Cleanup() }() g := new(errgroup.Group) @@ -111,17 +113,22 @@ func (c *envCollectorFifo) Diff() (changed []string, deleted []string, _ error) } func (c *envCollectorFifo) ExtraEnv() []string { - if c.encKey == nil || c.encNonce == nil { - return nil + result := []string{ + createEnv(EnvNameTerminalSessionPrePath, c.prePath()), + createEnv(EnvNameTerminalSessionPostPath, c.postPath()), } - return []string{ - "RUNME_ENCRYPTION_KEY=" + hex.EncodeToString(c.encKey), - "RUNME_ENCRYPTION_NONCE=" + hex.EncodeToString(c.encNonce), + if c.encKey != nil && c.encNonce != nil { + result = append( + result, + createEnv(envCollectorEncKeyEnvName, hex.EncodeToString(c.encKey)), + createEnv(envCollectorEncNonceEnvName, hex.EncodeToString(c.encNonce)), + ) } + return result } func (c *envCollectorFifo) SetOnShell(shell io.Writer) error { - return setOnShell(shell, c.prePath(), c.postPath()) + return setOnShell(shell, envDumpCommand, true, false, false, c.prePath(), c.postPath()) } func (c *envCollectorFifo) prePath() string { diff --git a/internal/command/env_collector_fifo_unix_test.go b/internal/command/env_collector_fifo_unix_test.go index 3cf04ae3a..93383b98a 100644 --- a/internal/command/env_collector_fifo_unix_test.go +++ b/internal/command/env_collector_fifo_unix_test.go @@ -4,13 +4,14 @@ package command import ( + "bytes" "os" "testing" "github.com/stretchr/testify/require" ) -func TestEnvCollectorFifo(t *testing.T) { +func Test_envCollectorFifo(t *testing.T) { t.Parallel() collector, err := newEnvCollectorFifo(scanEnv, nil, nil) @@ -21,13 +22,29 @@ func TestEnvCollectorFifo(t *testing.T) { err = os.WriteFile(collector.postPath(), []byte("ENV_2=2"), 0o600) require.NoError(t, err) - changedEnv, deletedEnv, err := collector.Diff() - require.NoError(t, err) - require.Equal(t, []string{"ENV_2=2"}, changedEnv) - require.Equal(t, []string{"ENV_1"}, deletedEnv) + t.Run("ExtraEnv", func(t *testing.T) { + require.Len(t, collector.ExtraEnv(), 2) + }) + + t.Run("SetOnShell", func(t *testing.T) { + buf := new(bytes.Buffer) + err := collector.SetOnShell(buf) + require.NoError(t, err) + expected := " env -0 > " + collector.prePath() + "\n" + + " __cleanup() {\nrv=$?\nenv -0 > " + collector.postPath() + "\nexit $rv\n}\n" + + " trap -- \"__cleanup\" EXIT\n" + require.Equal(t, expected, buf.String()) + }) + + t.Run("Diff", func(t *testing.T) { + changedEnv, deletedEnv, err := collector.Diff() + require.NoError(t, err) + require.Equal(t, []string{"ENV_2=2"}, changedEnv) + require.Equal(t, []string{"ENV_1"}, deletedEnv) + }) } -func TestEnvCollectorFifoWithoutWriter(t *testing.T) { +func Test_envCollectorFifo_WithoutWriter(t *testing.T) { t.Parallel() collector, err := newEnvCollectorFifo(scanEnv, nil, nil) diff --git a/internal/command/env_collector_file.go b/internal/command/env_collector_file.go index 9418c85fa..3f62ff79d 100644 --- a/internal/command/env_collector_file.go +++ b/internal/command/env_collector_file.go @@ -16,7 +16,7 @@ var _ envCollector = (*envCollectorFile)(nil) func newEnvCollectorFile( scanner envScanner, - encKey, + encKey []byte, encNonce []byte, ) (*envCollectorFile, error) { temp, err := newTempDirectory() @@ -33,7 +33,7 @@ func newEnvCollectorFile( } func (c *envCollectorFile) Diff() (changed []string, deleted []string, _ error) { - defer c.temp.Cleanup() + defer func() { _ = c.temp.Cleanup() }() initialReader, err := c.temp.Open(c.prePath()) if err != nil { @@ -61,17 +61,22 @@ func (c *envCollectorFile) Diff() (changed []string, deleted []string, _ error) } func (c *envCollectorFile) ExtraEnv() []string { - if c.encKey == nil || c.encNonce == nil { - return nil + result := []string{ + createEnv(EnvNameTerminalSessionPrePath, c.prePath()), + createEnv(EnvNameTerminalSessionPostPath, c.postPath()), } - return []string{ - envCollectorEncKeyEnvName + "=" + hex.EncodeToString(c.encKey), - envCollectorEncNonceEnvName + "=" + hex.EncodeToString(c.encNonce), + if c.encKey != nil && c.encNonce != nil { + result = append( + result, + createEnv(envCollectorEncKeyEnvName, hex.EncodeToString(c.encKey)), + createEnv(envCollectorEncNonceEnvName, hex.EncodeToString(c.encNonce)), + ) } + return result } func (c *envCollectorFile) SetOnShell(shell io.Writer) error { - return setOnShell(shell, c.prePath(), c.postPath()) + return setOnShell(shell, envDumpCommand, true, false, false, c.prePath(), c.postPath()) } func (c *envCollectorFile) prePath() string { diff --git a/internal/command/env_collector_file_test.go b/internal/command/env_collector_file_test.go index 31199072d..739783ab9 100644 --- a/internal/command/env_collector_file_test.go +++ b/internal/command/env_collector_file_test.go @@ -1,13 +1,14 @@ package command import ( + "bytes" "os" "testing" "github.com/stretchr/testify/require" ) -func TestEnvCollectorFile(t *testing.T) { +func Test_envCollectorFile(t *testing.T) { t.Parallel() collector, err := newEnvCollectorFile(scanEnv, nil, nil) @@ -18,8 +19,24 @@ func TestEnvCollectorFile(t *testing.T) { err = os.WriteFile(collector.postPath(), []byte("ENV_2=2"), 0o600) require.NoError(t, err) - changedEnv, deletedEnv, err := collector.Diff() - require.NoError(t, err) - require.Equal(t, []string{"ENV_2=2"}, changedEnv) - require.Equal(t, []string{"ENV_1"}, deletedEnv) + t.Run("ExtraEnv", func(t *testing.T) { + require.Len(t, collector.ExtraEnv(), 2) + }) + + t.Run("SetOnShell", func(t *testing.T) { + buf := new(bytes.Buffer) + err := collector.SetOnShell(buf) + require.NoError(t, err) + expected := " env -0 > " + collector.prePath() + "\n" + + " __cleanup() {\nrv=$?\nenv -0 > " + collector.postPath() + "\nexit $rv\n}\n" + + " trap -- \"__cleanup\" EXIT\n" + require.Equal(t, expected, buf.String()) + }) + + t.Run("Diff", func(t *testing.T) { + changedEnv, deletedEnv, err := collector.Diff() + require.NoError(t, err) + require.Equal(t, []string{"ENV_2=2"}, changedEnv) + require.Equal(t, []string{"ENV_1"}, deletedEnv) + }) } diff --git a/internal/command/env_shell.go b/internal/command/env_shell.go index d9d5785f4..5ffee8d87 100644 --- a/internal/command/env_shell.go +++ b/internal/command/env_shell.go @@ -1,8 +1,10 @@ package command import ( - "bytes" "io" + + "github.com/pkg/errors" + "go.uber.org/multierr" ) const StoreStdoutEnvName = "__" @@ -15,26 +17,75 @@ func createEnv(key, value string) string { return key + "=" + value } -func setOnShell(shell io.Writer, prePath, postPath string) error { - var err error +// ScriptEnvSetter returns a shell script that installs itself and +// collects environment variables to provided pre- and post-paths. +type ScriptEnvSetter struct { + debug bool + dumpCommand string + prePath string + postPath string +} - // Prefix commands with a space to avoid polluting the shell history. - skipShellHistory := " " +func NewScriptEnvSetter(prePath, postPath string, debug bool) ScriptEnvSetter { + return ScriptEnvSetter{ + debug: debug, + dumpCommand: envDumpCommand, + prePath: prePath, + postPath: postPath, + } +} - // First, dump all env at the beginning, so that a diff can be calculated. - _, err = shell.Write([]byte(skipShellHistory + envDumpCommand + " > " + prePath + "\n")) - if err != nil { +func (s ScriptEnvSetter) SetOnShell(shell io.Writer) error { + if err := s.validate(); err != nil { return err } + return setOnShell(shell, s.dumpCommand, false, true, s.debug, s.prePath, s.postPath) +} + +func (s ScriptEnvSetter) validate() (err error) { + if s.prePath == "" { + err = multierr.Append(err, errors.New("pre-path is required")) + } + if s.postPath == "" { + err = multierr.Append(err, errors.New("post-path is required")) + } + return +} + +func setOnShell( + shell io.Writer, + dumpCommand string, + skipShellHistory bool, + asFile bool, + debug bool, + prePath string, + postPath string, +) error { + prefix := "" + if skipShellHistory { + prefix = " " // space avoids polluting the shell history + } + + w := bulkWriter{Writer: shell} + + if asFile { + w.WriteString("#!/bin/sh\n") + } + + if debug { + w.WriteString("set -euxo pipefail\n") + } + + // Dump all env at the beginning, so that a diff can be calculated. + w.WriteString(prefix + dumpCommand + " > " + prePath + "\n") // Then, set a trap on EXIT to dump all env at the end. - _, err = shell.Write(bytes.Join( - [][]byte{ - []byte(skipShellHistory + "__cleanup() {\nrv=$?\n" + (envDumpCommand + " > " + postPath) + "\nexit $rv\n}"), - []byte(skipShellHistory + "trap -- \"__cleanup\" EXIT"), - nil, // add a new line at the end - }, - []byte{'\n'}, - )) + w.WriteString(prefix + "__cleanup() {\nrv=$?\n" + (envDumpCommand + " > " + postPath) + "\nexit $rv\n}\n") + w.WriteString(prefix + "trap -- \"__cleanup\" EXIT\n") + + if debug { + w.WriteString("set +euxo pipefail\n") + } + _, err := w.Done() return err } diff --git a/internal/command/env_shell_test.go b/internal/command/env_shell_test.go index ac7248a8d..3fa06b5be 100644 --- a/internal/command/env_shell_test.go +++ b/internal/command/env_shell_test.go @@ -10,19 +10,57 @@ import ( "github.com/stretchr/testify/require" ) -func TestSetOnShell(t *testing.T) { +func TestScriptEnvSetter(t *testing.T) { + t.Parallel() + + prePath := "/tmp/pre-path" + postPath := "/tmp/post-path" + + t.Run("WithDebug", func(t *testing.T) { + setter := NewScriptEnvSetter(prePath, postPath, true) + buf := new(bytes.Buffer) + + err := setter.SetOnShell(buf) + require.NoError(t, err) + + expected := "#!/bin/sh\n" + + "set -euxo pipefail\n" + + "env -0 > /tmp/pre-path\n" + + "__cleanup() {\nrv=$?\nenv -0 > /tmp/post-path\nexit $rv\n}\n" + + "trap -- \"__cleanup\" EXIT\n" + + "set +euxo pipefail\n" + require.EqualValues(t, expected, buf.String()) + }) + + t.Run("WithoutDebug", func(t *testing.T) { + setter := NewScriptEnvSetter(prePath, postPath, false) + buf := new(bytes.Buffer) + + err := setter.SetOnShell(buf) + require.NoError(t, err) + + expected := "#!/bin/sh\n" + + "env -0 > /tmp/pre-path\n" + + "__cleanup() {\nrv=$?\nenv -0 > /tmp/post-path\nexit $rv\n}\n" + + "trap -- \"__cleanup\" EXIT\n" + require.EqualValues(t, expected, buf.String()) + }) +} + +func TestSetOnShell_SkipShellHistory(t *testing.T) { t.Parallel() buf := new(bytes.Buffer) - err := setOnShell(buf, "prePath", "postPath") + err := setOnShell(buf, envDumpCommand, true, false, false, "prePath", "postPath") require.NoError(t, err) - expected := " " + - envDumpCommand + - " > prePath\n __cleanup() {\nrv=$?\n" + - envDumpCommand + - " > postPath\nexit $rv\n}\n trap -- \"__cleanup\" EXIT\n" + expected := (" " + envDumpCommand + " > prePath\n" + + " __cleanup() {\n" + + "rv=$?\n" + + envDumpCommand + " > postPath\n" + + "exit $rv\n}\n" + + " trap -- \"__cleanup\" EXIT\n") require.EqualValues(t, expected, buf.String()) } diff --git a/internal/command/factory.go b/internal/command/factory.go index 8d996a281..6d2656863 100644 --- a/internal/command/factory.go +++ b/internal/command/factory.go @@ -24,6 +24,10 @@ type CommandOptions struct { // with [virtualCommand]. EnableEcho bool + // NoShell, if true, disables detecting whether the program + // is a shell script. + NoShell bool + // Session is used to share the state between commands. // If none is provided, an empty one will be used. Session *session.Session @@ -80,6 +84,9 @@ func NewFactory(opts ...FactoryOption) Factory { for _, opt := range opts { opt(f) } + if f.logger == nil { + f.logger = zap.NewNop() + } return f } @@ -114,7 +121,7 @@ func (f *commandFactory) Build(cfg *ProgramConfig, opts CommandOptions) (Command switch mode { case runnerv2.CommandMode_COMMAND_MODE_INLINE: base := f.buildBase(cfg, opts) - if isShell(cfg) { + if !opts.NoShell && isShell(cfg) { collector, err := f.getEnvCollector() if err != nil { return nil, err @@ -147,7 +154,7 @@ func (f *commandFactory) Build(cfg *ProgramConfig, opts CommandOptions) (Command internal := f.buildNative(base) internal.disableNewProcessID = true - if isShell(cfg) { + if !opts.NoShell && isShell(cfg) { collector, err := f.getEnvCollector() if err != nil { return nil, err @@ -291,13 +298,7 @@ func (f *commandFactory) getEnvCollector() (envCollector, error) { if f.docker != nil { return nil, nil } - collectorFactory := newEnvCollectorFactory( - envCollectorFactoryOptions{ - encryptionEnabled: envCollectorEnableEncryption, - useFifo: envCollectorUseFifo, - }, - ) - return collectorFactory.Build() + return NewEnvCollectorFactory().Build() } func (f *commandFactory) getLogger(name string) *zap.Logger { diff --git a/internal/command/terminal_session.go b/internal/command/terminal_session.go new file mode 100644 index 000000000..3e80a41e9 --- /dev/null +++ b/internal/command/terminal_session.go @@ -0,0 +1,8 @@ +package command + +// Constants for supporting terminal session via the "beta session" command. +const ( + EnvNameTerminalSessionEnabled = "_RUNME_TERMINAL_SESSION_ENABLED" + EnvNameTerminalSessionPrePath = "_RUNME_TERMINAL_SESSION_PREPATH" + EnvNameTerminalSessionPostPath = "_RUNME_TERMINAL_SESSION_POSTPATH" +) diff --git a/internal/runnerv2client/client_test.go b/internal/runnerv2client/client_test.go index 459bfb19f..d2d8cb960 100644 --- a/internal/runnerv2client/client_test.go +++ b/internal/runnerv2client/client_test.go @@ -19,7 +19,7 @@ import ( ) func init() { - command.SetEnvDumpCommand("env -0") + command.SetEnvDumpCommandForTesting() } func TestClient_ExecuteProgram(t *testing.T) { diff --git a/internal/runnerv2service/service_test.go b/internal/runnerv2service/service_test.go index 804a770d3..c661dd5ec 100644 --- a/internal/runnerv2service/service_test.go +++ b/internal/runnerv2service/service_test.go @@ -9,7 +9,7 @@ import ( ) func init() { - command.SetEnvDumpCommand("env -0") + command.SetEnvDumpCommandForTesting() // Server uses autoconfig to get necessary dependencies. // One of them, implicit, is [config.Config]. With the default diff --git a/pkg/api/gen/proto/go/runme/runner/v2/config.pb.go b/pkg/api/gen/proto/go/runme/runner/v2/config.pb.go index 8dfeda94c..da1fcd4f9 100644 --- a/pkg/api/gen/proto/go/runme/runner/v2/config.pb.go +++ b/pkg/api/gen/proto/go/runme/runner/v2/config.pb.go @@ -28,6 +28,7 @@ const ( CommandMode_COMMAND_MODE_FILE CommandMode = 2 CommandMode_COMMAND_MODE_TERMINAL CommandMode = 3 CommandMode_COMMAND_MODE_CLI CommandMode = 4 + CommandMode_COMMAND_MODE_CLI_SESSION CommandMode = 5 ) // Enum value maps for CommandMode. @@ -38,6 +39,7 @@ var ( 2: "COMMAND_MODE_FILE", 3: "COMMAND_MODE_TERMINAL", 4: "COMMAND_MODE_CLI", + 5: "COMMAND_MODE_CLI_SESSION", } CommandMode_value = map[string]int32{ "COMMAND_MODE_UNSPECIFIED": 0, @@ -45,6 +47,7 @@ var ( "COMMAND_MODE_FILE": 2, "COMMAND_MODE_TERMINAL": 3, "COMMAND_MODE_CLI": 4, + "COMMAND_MODE_CLI_SESSION": 5, } ) @@ -354,7 +357,7 @@ var file_runme_runner_v2_config_proto_rawDesc = []byte{ 0x09, 0x52, 0x09, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x1a, 0x23, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, - 0x73, 0x42, 0x08, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2a, 0x8c, 0x01, 0x0a, 0x0b, + 0x73, 0x42, 0x08, 0x0a, 0x06, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2a, 0xaa, 0x01, 0x0a, 0x0b, 0x43, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1c, 0x0a, 0x18, 0x43, 0x4f, 0x4d, 0x4d, 0x41, 0x4e, 0x44, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x43, 0x4f, 0x4d, @@ -363,12 +366,14 @@ var file_runme_runner_v2_config_proto_rawDesc = []byte{ 0x44, 0x45, 0x5f, 0x46, 0x49, 0x4c, 0x45, 0x10, 0x02, 0x12, 0x19, 0x0a, 0x15, 0x43, 0x4f, 0x4d, 0x4d, 0x41, 0x4e, 0x44, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x12, 0x14, 0x0a, 0x10, 0x43, 0x4f, 0x4d, 0x4d, 0x41, 0x4e, 0x44, 0x5f, - 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x43, 0x4c, 0x49, 0x10, 0x04, 0x42, 0x4c, 0x5a, 0x4a, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x66, 0x75, - 0x6c, 0x2f, 0x72, 0x75, 0x6e, 0x6d, 0x65, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, - 0x70, 0x69, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, - 0x72, 0x75, 0x6e, 0x6d, 0x65, 0x2f, 0x72, 0x75, 0x6e, 0x6e, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x3b, - 0x72, 0x75, 0x6e, 0x6e, 0x65, 0x72, 0x76, 0x32, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x43, 0x4c, 0x49, 0x10, 0x04, 0x12, 0x1c, 0x0a, 0x18, 0x43, 0x4f, + 0x4d, 0x4d, 0x41, 0x4e, 0x44, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x43, 0x4c, 0x49, 0x5f, 0x53, + 0x45, 0x53, 0x53, 0x49, 0x4f, 0x4e, 0x10, 0x05, 0x42, 0x4c, 0x5a, 0x4a, 0x67, 0x69, 0x74, 0x68, + 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x66, 0x75, 0x6c, 0x2f, + 0x72, 0x75, 0x6e, 0x6d, 0x65, 0x2f, 0x76, 0x33, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x6f, 0x2f, 0x72, 0x75, + 0x6e, 0x6d, 0x65, 0x2f, 0x72, 0x75, 0x6e, 0x6e, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x3b, 0x72, 0x75, + 0x6e, 0x6e, 0x65, 0x72, 0x76, 0x32, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/pkg/api/gen/proto/gql/runme/runner/v2/runner.graphql b/pkg/api/gen/proto/gql/runme/runner/v2/runner.graphql index 4c6d8901a..744e0efea 100644 --- a/pkg/api/gen/proto/gql/runme/runner/v2/runner.graphql +++ b/pkg/api/gen/proto/gql/runme/runner/v2/runner.graphql @@ -13,6 +13,7 @@ enum CommandMode { COMMAND_MODE_FILE COMMAND_MODE_TERMINAL COMMAND_MODE_CLI + COMMAND_MODE_CLI_SESSION } input CreateSessionRequestInput { """ diff --git a/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.d.ts b/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.d.ts index b6cfe9873..e90030352 100644 --- a/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.d.ts +++ b/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.d.ts @@ -147,7 +147,11 @@ export declare enum CommandMode { /** * @generated from protobuf enum value: COMMAND_MODE_CLI = 4; */ - CLI = 4 + CLI = 4, + /** + * @generated from protobuf enum value: COMMAND_MODE_CLI_SESSION = 5; + */ + CLI_SESSION = 5 } declare class ProgramConfig$Type extends MessageType { constructor(); diff --git a/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.js b/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.js index d39f88749..784e7bc8b 100644 --- a/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.js +++ b/pkg/api/gen/proto/ts/runme/runner/v2/config_pb.js @@ -34,6 +34,10 @@ export var CommandMode; * @generated from protobuf enum value: COMMAND_MODE_CLI = 4; */ CommandMode[CommandMode["CLI"] = 4] = "CLI"; + /** + * @generated from protobuf enum value: COMMAND_MODE_CLI_SESSION = 5; + */ + CommandMode[CommandMode["CLI_SESSION"] = 5] = "CLI_SESSION"; })(CommandMode || (CommandMode = {})); // @generated message type with reflection information, may provide speed optimized methods class ProgramConfig$Type extends MessageType { diff --git a/pkg/api/proto/runme/runner/v2/config.proto b/pkg/api/proto/runme/runner/v2/config.proto index c38ff8a9b..4950d294f 100644 --- a/pkg/api/proto/runme/runner/v2/config.proto +++ b/pkg/api/proto/runme/runner/v2/config.proto @@ -6,9 +6,21 @@ option go_package = "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/run enum CommandMode { COMMAND_MODE_UNSPECIFIED = 0; + + // COMMAND_MODE_INLINE indicates that the command should be executed inline. + // For example: bash -c "echo 'Hello, World'" COMMAND_MODE_INLINE = 1; + + // COMMAND_MODE_FILE indicates that the command should be executed as a file. + // For example: bash /tmp/script.sh COMMAND_MODE_FILE = 2; + + // COMMAND_MODE_TERMINAL indicates that the command should be executed as a Runme Terminal. + // This is used by the VS Code extension. COMMAND_MODE_TERMINAL = 3; + + // COMMAND_MODE_CLI indicates that the command is executed via runme CLI. + // It is executed as a native command. COMMAND_MODE_CLI = 4; }