Skip to content
This repository has been archived by the owner on Apr 17, 2023. It is now read-only.

Commit

Permalink
feat(console): initial interactive console support
Browse files Browse the repository at this point in the history
This makes it possible to attach to an application's console using a
socket connection.

The implementation is based on Nomad's alloc exec.
  • Loading branch information
radriaanse committed Aug 18, 2021
1 parent eb5155c commit 120c1e4
Show file tree
Hide file tree
Showing 32 changed files with 4,222 additions and 1 deletion.
6 changes: 6 additions & 0 deletions cmd/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ func init() {
UI: defaultUI,
}, nil
},
"console": func() (cli.Command, error) {
return &command.ConsoleCommand{
Client: client,
UI: defaultUI,
}, nil
},
"delete": func() (cli.Command, error) {
return &command.DeleteCommand{
Projects: client.Projects,
Expand Down
301 changes: 301 additions & 0 deletions command/console.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
package command

import (
"bytes"
"context"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"

"github.com/gorilla/websocket"
"github.com/sloppyio/cli/pkg/api"
"github.com/sloppyio/cli/pkg/terminal"
"github.com/sloppyio/cli/ui"
)

const (
origin = "https://localhost/"
)

type ConsoleCommand struct {
Client *api.Client
UI ui.UI

Stdin io.Reader
Stdout io.WriteCloser
Stderr io.WriteCloser
}

func (c *ConsoleCommand) Help() string {
helpText := `
Usage: sloppy console [OPTIONS] (PROJECT/SERVICE/APP) (COMMAND)
Attach to the console session of an application.
If no command is specified an interactive session will be attempted,
this uses the -i -t flags and requires a working TTY.
Options:
-i attach stdin to the container
-t allocate a pseudo-tty
-e sets the escape character
`
return strings.TrimSpace(helpText)
}

func (c *ConsoleCommand) Synopsis() string {
return "Launch the console of an application"
}

func (c *ConsoleCommand) Run(args []string) int {
var stdinOpt, ttyOpt bool
var escapeChar, appPath string

cmdFlags := newFlagSet("console", flag.ContinueOnError)
cmdFlags.BoolVar(&stdinOpt, "i", true, "")
cmdFlags.BoolVar(&ttyOpt, "t", terminal.IsTTY(), "")
cmdFlags.StringVar(&escapeChar, "e", "~", "")

if err := cmdFlags.Parse(args); err != nil {
c.UI.Error(err.Error())
c.UI.Output("See 'sloppy change --help'.")
return 1
}

args = cmdFlags.Args()

if len(args) == 0 {
return c.UI.ErrorNotEnoughArgs("console", "", 1)
}

appPath = args[0]

if !(strings.Count(strings.Trim(appPath, "/"), "/") == 2) {
return c.UI.ErrorInvalidAppPath(args[0])
}

if ttyOpt && !stdinOpt {
c.UI.Error("-i must be enabled if running with tty")
return 1
}

if !stdinOpt {
c.Stdin = bytes.NewReader(nil)
}

if c.Stdin == nil {
c.Stdin = os.Stdin
}

if c.Stdout == nil {
c.Stdout = os.Stdout
}

if c.Stderr == nil {
c.Stderr = os.Stderr
}

code, err := c.consoleImpl(appPath, args[1:], ttyOpt, escapeChar, c.Stdin, c.Stdout, c.Stderr)
if err != nil {
return 1
}

return code
}

func (c *ConsoleCommand) consoleImpl(app string, command []string, tty bool, escapeChar string, stdin io.Reader, stdout, stderr io.WriteCloser) (int, error) {
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()

if tty {
if stdin == nil {
return 1, fmt.Errorf("stdin is required with TTY")
}

stdinRestore, err := terminal.SetRawInput(stdin)
if err != nil {
return 1, err
}
defer stdinRestore()

stdoutRestore, err := terminal.SetRawOutput(stdout)
if err != nil {
return 1, err
}
defer stdoutRestore()

if escapeChar != "" {
stdin = terminal.NewReader(stdin, escapeChar[0], func(b byte) bool {
switch b {
case '.':
stdoutRestore()
stdinRestore()

stderr.Write([]byte("\nClosed!\n"))
cancelFn()
return true
default:
return false
}
})
}
}

signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, os.Interrupt, syscall.SIGTERM)
go func() {
for range signalCh {
cancelFn()
}
}()

exec := &consoleExec{
client: c.Client,
app: app,

tty: tty,
command: command,

stdin: stdin,
stdout: stdout,
stderr: stderr,
}

return exec.run(ctx)
}

type consoleExec struct {
client *api.Client
app string

tty bool
command []string

stdin io.Reader
stdout io.Writer
stderr io.Writer
}

func (c *consoleExec) run(ctx context.Context) (int, error) {
ctx, cancelFn := context.WithCancel(ctx)
defer cancelFn()

ws, err := c.initConnection()
if err != nil {
return 1, err
}
defer ws.Close()

sendErrCh := c.setupSend(ctx, ws)
recvErrCh := c.setupReceive(ctx, ws)

for {
select {
case <-ctx.Done():
return 1, err
case sendErr := <-sendErrCh:
return 1, sendErr
case recvErr := <-recvErrCh:
return 1, recvErr
}
}
}

func (c *consoleExec) initConnection() (*websocket.Conn, error) {
url := fmt.Sprintf("%sconsole?token=%s&app=%s",
strings.Replace(c.client.GetBaseURL(), "https://", "wss://", 1),
strings.TrimPrefix(c.client.GetHeader("Authorization")[0], "Bearer "),
c.app,
)
headers := http.Header{
"Origin": []string{origin},
}

dialer := websocket.Dialer{}
conn, _, err := dialer.Dial(url, headers)
if err != nil {
return nil, err
}

return conn, nil
}

func (c *consoleExec) setupSend(ctx context.Context, conn *websocket.Conn) <-chan error {
var sendLock sync.Mutex

errCh := make(chan error, 4)
send := func(v []byte) {
sendLock.Lock()
defer sendLock.Unlock()

conn.WriteMessage(websocket.TextMessage, v)
}

// process stdin
go func() {
bytesIn := make([]byte, 2048)

for {
if ctx.Err() != nil {
return
}

n, err := c.stdin.Read(bytesIn)

if n != 0 {
send(bytesIn[:n])
}

if err != nil {
errCh <- err
return
}
}
}()

// send a heartbeat every 10 seconds
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(10 * time.Second):
send(nil)
}
}
}()

return errCh
}

func (c *consoleExec) setupReceive(ctx context.Context, conn *websocket.Conn) <-chan error {
errCh := make(chan error, 1)

go func() {
for ctx.Err() == nil {
_, d, err := conn.ReadMessage()
// check if the error is due to a closed connection
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
errCh <- fmt.Errorf("websocket closed before receiving exit code: %w", err)
return
} else if err != nil {
errCh <- err
return
}

if len(d) != 0 {
c.stdout.Write(d)
}
}
}()

return errCh
}
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ require (
github.com/bgentry/speakeasy v0.1.0 // indirect
github.com/docker/cli v0.0.0-20180208160652-a9ecf823ff3e
github.com/docker/distribution v2.6.0-rc.1.0.20180207001120-d707ea24281f+incompatible // indirect
github.com/docker/docker v17.12.0-ce-rc1.0.20180209023802-afbc9c4cfc5d+incompatible // indirect
github.com/docker/docker v17.12.0-ce-rc1.0.20180209023802-afbc9c4cfc5d+incompatible
github.com/docker/docker-credential-helpers v0.6.0 // indirect
github.com/docker/go-connections v0.3.0 // indirect
github.com/docker/go-units v0.3.2 // indirect
Expand All @@ -20,6 +20,7 @@ require (
github.com/google/go-cmp v0.5.4 // indirect
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f // indirect
github.com/gorilla/mux v1.6.1 // indirect
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce // indirect
github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874 // indirect
github.com/mattn/go-isatty v0.0.3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f h1:9oNbS1z4rVpbnkH
github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.1 h1:KOwqsTYZdeuMacU7CxjMNYEKeBvLbxW+psodrbcEa3A=
github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce h1:prjrVgOk2Yg6w+PflHoszQNLTUh4kaByUcEWM/9uin4=
github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v0.0.0-20171204182908-b7773ae21874 h1:em+tTnzgU7N22woTBMcSJAOW7tRHAkK597W+MD/CpK8=
Expand Down
48 changes: 48 additions & 0 deletions pkg/terminal/control.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package terminal

import (
"os"

"github.com/docker/docker/pkg/term"
)

// isTty returns true if both stdin and stdout are a TTY.
func IsTTY() bool {
_, isStdinTerminal := term.GetFdInfo(os.Stdin)
_, isStdoutTerminal := term.GetFdInfo(os.Stdout)
return isStdinTerminal && isStdoutTerminal
}

// setRawInput sets the stream terminal in raw mode, so process captures
// Ctrl+C and other commands to forward to remote process.
// It returns a cleanup function that restores terminal to original mode.
func SetRawInput(stream interface{}) (cleanup func(), err error) {
fd, isTerminal := term.GetFdInfo(stream)
if !isTerminal {
return nil, err
}

state, err := term.SetRawTerminal(fd)
if err != nil {
return nil, err
}

return func() { term.RestoreTerminal(fd, state) }, nil
}

// setRawOutput sets the output stream in Windows to raw mode,
// so it disables LF -> CRLF translation.
// It's basically a no-op on unix.
func SetRawOutput(stream interface{}) (cleanup func(), err error) {
fd, isTerminal := term.GetFdInfo(stream)
if !isTerminal {
return nil, err
}

state, err := term.SetRawTerminalOutput(fd)
if err != nil {
return nil, err
}

return func() { term.RestoreTerminal(fd, state) }, nil
}
Loading

0 comments on commit 120c1e4

Please sign in to comment.