Skip to content

Commit

Permalink
APIs to support Terminal integration (#638)
Browse files Browse the repository at this point in the history
First stab at providing a session fork/rollup terminal.
  • Loading branch information
sourishkrout authored Aug 6, 2024
1 parent c7ed2e7 commit 760505e
Show file tree
Hide file tree
Showing 10 changed files with 204 additions and 13 deletions.
24 changes: 22 additions & 2 deletions internal/cmd/beta/beta_cmd.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package beta

import (
"fmt"
"io"

"github.com/spf13/cobra"
"github.com/spf13/pflag"

Expand All @@ -12,6 +15,8 @@ import (
type commonFlags struct {
categories []string
filename string
insecure bool
silent bool
}

func BetaCmd() *cobra.Command {
Expand All @@ -27,7 +32,11 @@ All commands are experimental and not yet ready for production use.
All commands use the runme.yaml configuration file.`,
Hidden: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return autoconfig.InvokeForCommand(func(cfg *config.Config) error {
if cFlags.silent {
cmd.SetErr(io.Discard)
}

err := autoconfig.InvokeForCommand(func(cfg *config.Config) error {
// Override the filename if provided.
if cFlags.filename != "" {
cfg.ProjectFilename = cFlags.filename
Expand All @@ -44,6 +53,14 @@ All commands use the runme.yaml configuration file.`,

return nil
})

// print the error to stderr but don't return it because error modes
// are neither fully baked yet nor ready for users to consume
if err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", err)
}

return nil
},
}

Expand All @@ -52,8 +69,10 @@ All commands use the runme.yaml configuration file.`,
// Use them sparingly and only for the cases when it does not make sense
// to alter the configuration file.
pFlags := cmd.PersistentFlags()
pFlags.StringVar(&cFlags.filename, "filename", "", "Name of the Markdown file to run blocks from.")
pFlags.StringSliceVar(&cFlags.categories, "category", nil, "Run blocks only from listed categories.")
pFlags.StringVar(&cFlags.filename, "filename", "", "Name of the Markdown file to run blocks from.")
pFlags.BoolVar(&cFlags.insecure, "insecure", false, "Explicitly allow delicate operations to prevent misuse")
pFlags.BoolVar(&cFlags.silent, "silent", false, "Silent mode. Do not print error messages.")

// Hide all persistent flags from the root command.
// "beta" is a completely different set of commands and
Expand All @@ -73,6 +92,7 @@ All commands use the runme.yaml configuration file.`,
cmd.AddCommand(printCmd(cFlags))
cmd.AddCommand(server.Cmd())
cmd.AddCommand(runCmd(cFlags))
cmd.AddCommand(envCmd(cFlags))

return &cmd
}
119 changes: 119 additions & 0 deletions internal/cmd/beta/env_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package beta

import (
"fmt"
"os"
"strings"

"github.com/pkg/errors"
"github.com/spf13/cobra"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"

runmetls "github.com/stateful/runme/v3/internal/tls"
runnerv2 "github.com/stateful/runme/v3/pkg/api/gen/proto/go/runme/runner/v2alpha1"
)

func envCmd(cflags *commonFlags) *cobra.Command {
cmd := cobra.Command{
Use: "env",
Aliases: []string{"environment"},
Hidden: true,
Short: "Environment management",
Long: "Various commands to manage environments in runme",
}

cmd.AddCommand(envSourceCmd(cflags))

return &cmd
}

func envSourceCmd(cflags *commonFlags) *cobra.Command {
var (
serverAddr string
sessionID string
sessionStrategy string
tlsDir string
fExport bool
)

cmd := cobra.Command{
Use: "source",
Short: "Source environment variables from session",
Long: "Source environment variables from session",
RunE: func(cmd *cobra.Command, args []string) error {
// discard any stderr in silent mode
if !cflags.insecure {
return errors.New("must be run in insecure mode to prevent misuse; enable by adding --insecure flag")
}

tlsConfig, err := runmetls.LoadClientConfigFromDir(tlsDir)
if err != nil {
return err
}

credentials := credentials.NewTLS(tlsConfig)
conn, err := grpc.Dial(serverAddr, grpc.WithTransportCredentials(credentials))
if err != nil {
return errors.Wrap(err, "failed to connect")
}
defer conn.Close()

client := runnerv2.NewRunnerServiceClient(conn)

// todo(sebastian): would it be better to require a specific session?
if strings.ToLower(sessionStrategy) == "recent" {
req := &runnerv2.ListSessionsRequest{}
resp, err := client.ListSessions(cmd.Context(), req)
if err != nil {
return err
}
l := len(resp.Sessions)
if l == 0 {
return errors.New("no sessions found")
}
// potentially unreliable
sessionID = resp.Sessions[l-1].Id
}

req := &runnerv2.GetSessionRequest{Id: sessionID}
resp, err := client.GetSession(cmd.Context(), req)
if err != nil {
return err
}

for _, kv := range resp.Session.Env {
parts := strings.Split(kv, "=")
if len(parts) < 2 {
return errors.Errorf("invalid key-value pair: %s", kv)
}

envVar := fmt.Sprintf("%s=%q", parts[0], strings.Join(parts[1:], "="))
if fExport {
envVar = fmt.Sprintf("export %s", envVar)
}

if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\n", envVar); err != nil {
return err
}
}

return nil
},
}

cmd.Flags().StringVar(&serverAddr, "server-address", os.Getenv("RUNME_SERVER_ADDR"), "The Server ServerAddress to connect to, i.e. 127.0.0.1:7865")
cmd.Flags().StringVar(&tlsDir, "tls-dir", os.Getenv("RUNME_TLS_DIR"), "Path to tls files")
cmd.Flags().StringVar(&sessionID, "session", os.Getenv("RUNME_SESSION"), "Session Id")
cmd.Flags().StringVar(&sessionStrategy, "session-strategy", func() string {
if val, ok := os.LookupEnv("RUNME_SESSION_STRATEGY"); ok {
return val
}

return "manual"
}(), "Strategy for session selection. Options are manual, recent. Defaults to manual")

cmd.Flags().BoolVarP(&fExport, "export", "", false, "export variables")

return &cmd
}
6 changes: 3 additions & 3 deletions internal/cmd/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ func storeSnapshotCmd() *cobra.Command {
},
}

cmd.Flags().StringVar(&serverAddr, "ServerAddress", os.Getenv("RUNME_SERVER_ADDR"), "The Server ServerAddress to connect to, i.e. 127.0.0.1:7865")
cmd.Flags().StringVar(&tlsDir, "TLSDir", os.Getenv("RUNME_TLS_DIR"), "Path to tls files")
cmd.Flags().StringVar(&serverAddr, "server-address", os.Getenv("RUNME_SERVER_ADDR"), "The Server ServerAddress to connect to, i.e. 127.0.0.1:7865")
cmd.Flags().StringVar(&tlsDir, "tls-dir", os.Getenv("RUNME_TLS_DIR"), "Path to tls files")
cmd.Flags().StringVar(&sessionID, "session", os.Getenv("RUNME_SESSION"), "Session Id")
cmd.Flags().StringVar(&sessionStrategy, "session-strategy", func() string {
if val, ok := os.LookupEnv("RUNME_SESSION_STRATEGY"); ok {
Expand Down Expand Up @@ -204,7 +204,7 @@ func environmentDumpCmd() *cobra.Command {
Long: "Dumps all environment variables to stdout as a list of K=V separated by null terminators",
RunE: func(cmd *cobra.Command, args []string) error {
if !fInsecure {
return errors.New("must be run in insecure mode; enable by running with --insecure flag")
return errors.New("must be run in insecure mode to prevent misuse; enable by adding --insecure flag")
}

producer, err := newOSEnvironReader()
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ func Root() *cobra.Command {

pflags.StringVar(&fChdir, "chdir", getCwd(), "Switch to a different working directory before executing the command")
pflags.StringVar(&fFileName, "filename", "README.md", "Name of the README file")
pflags.BoolVar(&fInsecure, "insecure", false, "Run command in insecure-mode")
pflags.BoolVar(&fInsecure, "insecure", false, "Explicitly allow insecure operations to prevent misuse")

pflags.StringVar(&fProject, "project", "", "Root project to find runnable tasks")
pflags.BoolVar(&fRespectGitignore, "git-ignore", true, "Whether to respect .gitignore file(s) in project")
Expand Down
16 changes: 14 additions & 2 deletions internal/command/command_terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,21 @@ func (c *terminalCommand) Start(ctx context.Context) (err error) {
c.logger.Info("a terminal command started")

if c.envCollector != nil {
return c.envCollector.SetOnShell(c.stdinWriter)
if err := c.envCollector.SetOnShell(c.stdinWriter); err != nil {
return err
}
}
return nil

if _, err := c.stdinWriter.Write([]byte(" eval $(runme beta env source --silent --insecure --export)\n clear\n")); err != nil {
return err
}

// todo(sebastian): good enough for prototype; it makes more sense to write this message at the TTY-level
initMsg := []byte(" # Runme: This terminal forked your session. " +
"Upon exit exported environment variables will be rolled up into the session.\n\n")
_, err = c.stdinWriter.Write(initMsg)

return err
}

func (c *terminalCommand) Wait() (err error) {
Expand Down
6 changes: 5 additions & 1 deletion internal/command/env_collector_fifo_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ type envCollectorFifo struct {
temp *tempDirectory
}

func newEnvCollectorFifo(scanner envScanner, encKey, encNonce []byte) (*envCollectorFifo, error) {
func newEnvCollectorFifo(
scanner envScanner,
encKey,
encNonce []byte,
) (*envCollectorFifo, error) {
temp, err := newTempDirectory()
if err != nil {
return nil, err
Expand Down
2 changes: 1 addition & 1 deletion internal/command/env_collector_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var _ envCollector = (*envCollectorFile)(nil)

func newEnvCollectorFile(
scanner envScanner,
encKey []byte,
encKey,
encNonce []byte,
) (*envCollectorFile, error) {
temp, err := newTempDirectory()
Expand Down
11 changes: 8 additions & 3 deletions internal/command/env_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,24 @@ import (

func setOnShell(shell io.Writer, prePath, postPath string) error {
var err error

// Prefix commands with a space to avoid polluting the shell history.
skipShellHistory := " "

// First, dump all env at the beginning, so that a diff can be calculated.
_, err = shell.Write([]byte(envDumpCommand + " > " + prePath + "\n"))
_, err = shell.Write([]byte(skipShellHistory + envDumpCommand + " > " + prePath + "\n"))
if err != nil {
return err
}
// Then, set a trap on EXIT to dump all env at the end.
_, err = shell.Write(bytes.Join(
[][]byte{
[]byte("__cleanup() {\nrv=$?\n" + (envDumpCommand + " > " + postPath) + "\nexit $rv\n}"),
[]byte("trap -- \"__cleanup\" EXIT"),
[]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'},
))

return err
}
28 changes: 28 additions & 0 deletions internal/command/env_shell_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build !windows
// +build !windows

package command

import (
"bytes"
"testing"

"github.com/stretchr/testify/require"
)

func TestSetOnShell(t *testing.T) {
t.Parallel()

buf := new(bytes.Buffer)

err := setOnShell(buf, "prePath", "postPath")
require.NoError(t, err)

expected := " " +
envDumpCommand +
" > prePath\n __cleanup() {\nrv=$?\n" +
envDumpCommand +
" > postPath\nexit $rv\n}\n trap -- \"__cleanup\" EXIT\n"

require.EqualValues(t, expected, buf.String())
}
3 changes: 3 additions & 0 deletions internal/runnerv2service/service_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runnerv2service
import (
"context"

"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"

Expand Down Expand Up @@ -45,6 +46,8 @@ func (r *runnerService) CreateSession(ctx context.Context, req *runnerv2alpha1.C

r.sessions.Add(sess)

r.logger.Debug("created session", zap.String("id", sess.ID))

return &runnerv2alpha1.CreateSessionResponse{
Session: convertSessionToRunnerv2alpha1Session(sess),
}, nil
Expand Down

0 comments on commit 760505e

Please sign in to comment.