Skip to content

Commit

Permalink
Improve exec functionality (#113)
Browse files Browse the repository at this point in the history
Fixes #107

This is a big change that does the following:
- replaces the bespoke exec implementation with an impl inspired from the nomad cli, using the client directly
- add a wander exec command
- therefore tls is supported
- improves general exec experience (can move cursor, does not strip ansi escape sequences, etc)
- BREAKAGE: exec with wander serve breaks. Unfortunately with this impl, tea.ExecProcess is used, which is currently charmbracelet/wish#196
  • Loading branch information
robinovitch61 committed Dec 1, 2023
1 parent 82af204 commit 312277a
Show file tree
Hide file tree
Showing 20 changed files with 557 additions and 392 deletions.
52 changes: 52 additions & 0 deletions cmd/exec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cmd

import (
"fmt"
"github.com/robinovitch61/wander/internal/tui/nomad"
"github.com/spf13/cobra"
"os"
)

var (
execCmd = &cobra.Command{
Use: "exec",
Short: "Exec into a running task",
Long: `Exec into a running nomad task`,
Example: `
# specify job and task, assuming single allocation
wander exec alright_stop --task redis echo "hi"
# specify allocation, assuming single task
wander exec 3dca0982 echo "hi"
# use prefixes of jobs or allocation ids
wander exec al echo "hi"
wander exec 3d echo "hi"
# specify flags for the exec command with --
wander exec alright_stop --task redis -- echo -n "hi"
`,
Run: execEntrypoint,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return nil },
}
)

func execEntrypoint(cmd *cobra.Command, args []string) {
task := cmd.Flags().Lookup("task").Value.String()
client, err := getConfig(cmd, "").Client()
if err != nil {
fmt.Println(fmt.Errorf("could not get client: %v", err))
os.Exit(1)
}
allocID := args[0]
execArgs := args[1:]
if len(execArgs) == 0 {
fmt.Println("no command specified")
os.Exit(1)
}
_, err = nomad.AllocExec(client, allocID, task, execArgs)
if err != nil {
fmt.Println(fmt.Errorf("could not exec into task: %v", err))
os.Exit(1)
}
}
4 changes: 4 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,11 @@ func init() {
viper.BindPFlag(cliLong, serveCmd.PersistentFlags().Lookup(c.cfgFileEnvVar))
}

// exec
execCmd.PersistentFlags().StringP("task", "", "", "Sets the task to exec command in")

rootCmd.AddCommand(serveCmd)
rootCmd.AddCommand(execCmd)
}

func initConfig(cmd *cobra.Command, nameToArg map[string]arg) error {
Expand Down
10 changes: 7 additions & 3 deletions cmd/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ func customLoggingMiddleware() wish.Middleware {
}
}

func setup(cmd *cobra.Command, overrideToken string) (app.Model, []tea.ProgramOption) {
func getConfig(cmd *cobra.Command, overrideToken string) app.Config {
nomadAddr := retrieveAddress(cmd)
nomadToken := retrieveToken(cmd)
if overrideToken != "" {
Expand Down Expand Up @@ -322,7 +322,7 @@ func setup(cmd *cobra.Command, overrideToken string) (app.Model, []tea.ProgramOp
startFiltering := retrieveStartFiltering(cmd)
filterWithContext := retrieveFilterWithContext(cmd)

initialModel := app.InitialModel(app.Config{
return app.Config{
Version: getVersion(),
URL: nomadAddr,
Token: nomadToken,
Expand Down Expand Up @@ -358,6 +358,10 @@ func setup(cmd *cobra.Command, overrideToken string) (app.Model, []tea.ProgramOp
CompactTables: compactTables,
StartFiltering: startFiltering,
FilterWithContext: filterWithContext,
})
}
}

func setup(cmd *cobra.Command, overrideToken string) (app.Model, []tea.ProgramOption) {
initialModel := app.InitialModel(getConfig(cmd, overrideToken))
return initialModel, []tea.ProgramOption{tea.WithAltScreen()}
}
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@ require (
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/nomad/api v0.0.0-20230619092614-e29ad68c588d
github.com/itchyny/gojq v0.12.13
github.com/moby/term v0.5.0
github.com/olekukonko/tablewriter v0.0.5
github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
)

require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/caarlos0/sshmarshal v0.1.0 // indirect
Expand Down Expand Up @@ -54,7 +57,6 @@ require (
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
golang.org/x/crypto v0.10.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
golang.org/x/sync v0.3.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/term v0.9.0 // indirect
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
Expand Down Expand Up @@ -73,6 +75,8 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -207,6 +211,8 @@ github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJ
github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
Expand Down Expand Up @@ -402,6 +408,7 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
Binary file modified img/wander.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
135 changes: 38 additions & 97 deletions internal/tui/components/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/gorilla/websocket"
"github.com/hashicorp/nomad/api"
"github.com/itchyny/gojq"
"github.com/robinovitch61/wander/internal/dev"
Expand All @@ -16,7 +15,7 @@ import (
"github.com/robinovitch61/wander/internal/tui/message"
"github.com/robinovitch61/wander/internal/tui/nomad"
"github.com/robinovitch61/wander/internal/tui/style"
"os"
"os/exec"
"strings"
"time"
)
Expand Down Expand Up @@ -77,19 +76,15 @@ type Model struct {

updateID int

lastExecContent string

eventsStream nomad.EventsStream
event string
meta map[string]string

logsStream nomad.LogsStream
lastLogFinished bool

execWebSocket *websocket.Conn
execPty *os.File
inPty bool
webSocketConnected bool
lastCommandFinished struct{ stdOut, stdErr bool }

width, height int
initialized bool
err error
Expand All @@ -110,7 +105,7 @@ func InitialModel(c Config) Model {
c.LogoColor,
c.URL,
c.Version,
nomad.GetPageKeyHelp(firstPage, false, false, false, false, false, false, nomad.StdOut, false, !c.StartAllTasksView),
nomad.GetPageKeyHelp(firstPage, false, false, false, nomad.StdOut, false, !c.StartAllTasksView),
)
return Model{
config: c,
Expand Down Expand Up @@ -163,10 +158,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, m.getCurrentPageCmd())
} else {
m.setPageWindowSize()
if m.currentPage == nomad.ExecPage {
viewportHeightWithoutFooter := m.getCurrentPageModel().ViewportHeight() - 1 // hardcoded as known today, has to change if footer expands
cmds = append(cmds, nomad.ResizeTty(m.execWebSocket, m.width, viewportHeightWithoutFooter))
}
}

case nomad.PageLoadedMsg:
Expand Down Expand Up @@ -265,43 +256,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateID = nextUpdateID()
}

case message.PageInputReceivedMsg:
case nomad.ExecCompleteMsg:
if m.currentPage == nomad.ExecPage {
m.getCurrentPageModel().SetLoading(true)
return m, nomad.InitiateWebSocket(m.config.URL, m.config.Token, m.alloc.ID, m.taskName, msg.Input)
m.getCurrentPageModel().SetDoesNeedNewInput()
m.lastExecContent = strings.TrimSpace(msg.Output)
m.setPage(nomad.ExecCompletePage)
cmds = append(cmds, m.getCurrentPageCmd())
}

case nomad.ExecWebSocketConnectedMsg:
m.execWebSocket = msg.WebSocketConnection
m.webSocketConnected = true
m.getCurrentPageModel().SetLoading(false)
m.setInPty(true)
viewportHeightWithoutFooter := m.getCurrentPageModel().ViewportHeight() - 1 // hardcoded as known today, has to change if footer expands
cmds = append(cmds, nomad.ResizeTty(m.execWebSocket, m.width, viewportHeightWithoutFooter))
cmds = append(cmds, nomad.ReadExecWebSocketNextMessage(m.execWebSocket))
cmds = append(cmds, nomad.SendHeartbeatWithDelay())

case nomad.ExecWebSocketHeartbeatMsg:
if m.currentPage == nomad.ExecPage && m.webSocketConnected {
cmds = append(cmds, nomad.SendHeartbeat(m.execWebSocket))
cmds = append(cmds, nomad.SendHeartbeatWithDelay())
return m, tea.Batch(cmds...)
}
return m, nil

case nomad.ExecWebSocketResponseMsg:
case message.PageInputReceivedMsg:
if m.currentPage == nomad.ExecPage {
if msg.Close {
m.webSocketConnected = false
m.setInPty(false)
m.getCurrentPageModel().AppendToViewport([]page.Row{{Row: constants.ExecWebSocketClosed}}, true)
m.getCurrentPageModel().ScrollViewportToBottom()
} else {
m.appendToViewport(msg.StdOut, m.lastCommandFinished.stdOut)
m.appendToViewport(msg.StdErr, m.lastCommandFinished.stdErr)
m.updateLastCommandFinished(msg.StdOut, msg.StdErr)
cmds = append(cmds, nomad.ReadExecWebSocketNextMessage(m.execWebSocket))
}
c := exec.Command("wander", "exec", m.alloc.ID, "--task", m.taskName, msg.Input)
stdoutProxy := &nomad.StdoutProxy{}
c.Stdout = stdoutProxy
m.getCurrentPageModel().SetDoesNeedNewInput()
return m, tea.ExecProcess(c, func(err error) tea.Msg {
return nomad.ExecCompleteMsg{Output: string(stdoutProxy.SavedOutput)}
})
}
}

Expand All @@ -328,7 +299,7 @@ func (m Model) View() string {
}

func (m *Model) initialize() error {
client, err := m.config.client()
client, err := m.config.Client()
if err != nil {
return err
}
Expand All @@ -355,9 +326,6 @@ func (m *Model) initialize() error {

func (m *Model) cleanupCmd() tea.Cmd {
return func() tea.Msg {
if m.execWebSocket != nil {
nomad.CloseWebSocket(m.execWebSocket)()
}
return message.CleanupCompleteMsg{}
}
}
Expand All @@ -377,28 +345,12 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
addingQToFilter := m.currentPageFilterFocused()
saving := m.currentPageViewportSaving()
enteringInput := currentPageModel != nil && currentPageModel.EnteringInput()
typingQLegitimately := msg.String() == "q" && (addingQToFilter || saving || enteringInput || m.inPty)
ctrlCInPty := m.inPty && msg.String() == "ctrl+c"
if (!ctrlCInPty && !typingQLegitimately) || m.err != nil {
typingQLegitimately := msg.String() == "q" && (addingQToFilter || saving || enteringInput)
if !typingQLegitimately || m.err != nil {
return m.cleanupCmd()
}
}

if m.currentPage == nomad.ExecPage {
var keypress string
if m.inPty {
if key.Matches(msg, keymap.KeyMap.Back) {
m.setInPty(false)
return nil
} else {
keypress = nomad.GetKeypress(msg)
return nomad.SendWebSocketMessage(m.execWebSocket, keypress)
}
} else if key.Matches(msg, keymap.KeyMap.Forward) && m.webSocketConnected && !m.currentPageViewportSaving() {
m.setInPty(true)
}
}

if !m.currentPageFilterFocused() && !m.currentPageViewportSaving() {
switch {
case key.Matches(msg, keymap.KeyMap.Compact):
Expand Down Expand Up @@ -436,9 +388,6 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
if !m.currentPageFilterApplied() {
switch m.currentPage {
case nomad.ExecPage:
if !m.getCurrentPageModel().EnteringInput() {
cmds = append(cmds, nomad.CloseWebSocket(m.execWebSocket))
}
m.getCurrentPageModel().SetDoesNeedNewInput()
}

Expand Down Expand Up @@ -618,32 +567,8 @@ func (m *Model) appendToViewport(content string, startOnNewLine bool) {
m.getCurrentPageModel().ScrollViewportToBottom()
}

// updateLastCommandFinished updates lastCommandFinished, which is necessary
// because some data gets received in chunks in which a trailing \n indicates
// finished content, otherwise more content is expected (e.g. the exec
// websocket behaves this way when returning long content)
func (m *Model) updateLastCommandFinished(stdOut, stdErr string) {
m.lastCommandFinished.stdOut = false
m.lastCommandFinished.stdErr = false
if strings.HasSuffix(stdOut, "\n") {
m.lastCommandFinished.stdOut = true
}
if strings.HasSuffix(stdErr, "\n") {
m.lastCommandFinished.stdErr = true
}
}

func (m *Model) setInPty(inPty bool) {
m.inPty = inPty
m.getCurrentPageModel().SetViewportPromptVisible(inPty)
if inPty {
m.getCurrentPageModel().ScrollViewportToBottom()
}
m.updateKeyHelp()
}

func (m *Model) updateKeyHelp() {
newKeyHelp := nomad.GetPageKeyHelp(m.currentPage, m.currentPageFilterFocused(), m.currentPageFilterApplied(), m.currentPageViewportSaving(), m.getCurrentPageModel().EnteringInput(), m.inPty, m.webSocketConnected, m.logType, m.compact, m.inJobsMode)
newKeyHelp := nomad.GetPageKeyHelp(m.currentPage, m.currentPageFilterFocused(), m.currentPageFilterApplied(), m.currentPageViewportSaving(), m.logType, m.compact, m.inJobsMode)
m.header.SetKeyHelp(newKeyHelp)
}

Expand Down Expand Up @@ -682,7 +607,23 @@ func (m Model) getCurrentPageCmd() tea.Cmd {
case nomad.JobTasksPage:
return nomad.FetchTasksForJob(m.client, m.jobID, m.jobNamespace, m.config.JobTaskColumns)
case nomad.ExecPage:
return nomad.LoadExecPage()
return func() tea.Msg {
// this does no async work, just moves to request the command input
return nomad.PageLoadedMsg{Page: nomad.ExecPage, TableHeader: []string{}, AllPageRows: []page.Row{}}
}
case nomad.ExecCompletePage:
return func() tea.Msg {
// this does no async work, just shows the output of the prior exec session
var allPageRows []page.Row
for _, row := range strings.Split(m.lastExecContent, "\n") {
row = strings.ReplaceAll(row, "\r", "")
if len(row) == 0 {
continue
}
allPageRows = append(allPageRows, page.Row{Row: formatter.StripOSCommandSequences(formatter.StripANSI(row))})
}
return nomad.PageLoadedMsg{Page: nomad.ExecCompletePage, TableHeader: []string{"Exec Session Output"}, AllPageRows: allPageRows}
}
case nomad.AllocSpecPage:
return nomad.FetchAllocSpec(m.client, m.alloc.ID)
case nomad.LogsPage:
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/components/app/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func nextUpdateID() int {
return updateID
}

func (c Config) client() (*api.Client, error) {
func (c Config) Client() (*api.Client, error) {
config := &api.Config{
Address: c.URL,
SecretID: c.Token,
Expand Down
Loading

0 comments on commit 312277a

Please sign in to comment.